From 80455376f01f19c10a06dc08b0629486c017bf79 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 20:02:04 -0700 Subject: [PATCH 01/34] perf(orchestration): thread snapshots send each long ID once (#24643) Co-authored-by: Claude Opus 5.5 --- .../src/orchestration-v2/ThreadStream.ts | 4 + .../boundedSnapshotTransport.test.ts | 57 ++++++++ apps/server/src/orchestration-v2/http.ts | 4 + apps/server/src/ws.ts | 2 + .../state/boundedThreadSnapshotHttp.test.ts | 18 ++- .../src/state/boundedThreadSnapshotHttp.ts | 2 +- .../src/state/threadHistoryHttp.ts | 1 + packages/client-runtime/src/state/threads.ts | 6 +- packages/contracts/src/baseSchemas.ts | 11 +- packages/contracts/src/environmentHttp.ts | 2 + .../contracts/src/orchestrationV2.test.ts | 18 +++ packages/contracts/src/orchestrationV2.ts | 110 ++++++++------- .../src/orchestrationV2IdTable.test.ts | 63 +++++++++ .../contracts/src/orchestrationV2IdTable.ts | 125 ++++++++++++++++++ 14 files changed, 369 insertions(+), 54 deletions(-) create mode 100644 packages/contracts/src/orchestrationV2IdTable.test.ts create mode 100644 packages/contracts/src/orchestrationV2IdTable.ts diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 8e8323bcff76..3c4baab80ef0 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -41,6 +41,7 @@ export function boundedSnapshotResponseFields(input: { readonly bounded: BoundedProjectionResult; readonly compactTurnItems: boolean; readonly dedupeBodies: boolean; + readonly compactIds: boolean; }) { const { bounded } = input; // Dedupe keeps shared items one object, which the compaction's identity check needs. @@ -54,6 +55,7 @@ export function boundedSnapshotResponseFields(input: { hasMoreHistory: bounded.hasMoreHistory, latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, ...(compactProjection === null ? {} : { turnItemsOmitLocalVisible: true as const }), + ...(input.compactIds ? { idTable: true as const } : {}), }; } @@ -62,6 +64,7 @@ export function buildBoundedThreadStreamSnapshot(input: { readonly projection: OrchestrationV2ThreadProjection; readonly compactTurnItems?: boolean; readonly dedupeBodies?: boolean; + readonly compactIds?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, @@ -74,6 +77,7 @@ export function buildBoundedThreadStreamSnapshot(input: { bounded, compactTurnItems: input.compactTurnItems === true, dedupeBodies: input.dedupeBodies === true, + compactIds: input.compactIds === true, }), }; } diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index ec22a06a1935..220e707b6c73 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -15,6 +15,7 @@ import { ORCHESTRATION_PROTOCOL_HEADER, ORCHESTRATION_PROTOCOL_VERSION, OrchestrationV2ThreadBoundedSnapshot, + OrchestrationV2ThreadHistoryPage, OrchestrationV2ThreadStreamItem, ProjectId, ProviderDriverKind, @@ -53,6 +54,9 @@ import * as ThreadManagementService from "./ThreadManagementService.ts"; const decodeBounded = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadBoundedSnapshot)), ); +const decodeHistoryPage = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadHistoryPage)), +); const decodeStreamItem = Schema.decodeUnknownSync( Schema.toCodecJson(OrchestrationV2ThreadStreamItem), ); @@ -81,6 +85,10 @@ const management = Layer.unwrap( getThreadSnapshot: (id) => projections.getThreadSnapshot(id).pipe(Effect.orDie), getThreadSnapshotWindow: (id, options) => projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), + getThreadHistoryPage: (id, cursor, throughEntryId, conversationOnly) => + projections + .getThreadHistoryPage(id, cursor, throughEntryId, conversationOnly) + .pipe(Effect.orDie), streamStoredEventsFrom: (input) => input?.threadId === BODIES ? Stream.fromIterable(bodiesLiveTail()) : Stream.empty, }); @@ -492,6 +500,46 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { ), ); + it.effect("HTTP: the ID table is opt-in and decodes to the same snapshot", () => + withHttp(async (get) => { + const base = `/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded?compactTurnItems=1`; + const plainBody = await (await get(base)).text(); + const tableBody = await (await get(`${base}&compactIds=1`)).text(); + const wire = JSON.parse(tableBody); + + assert.notProperty(JSON.parse(plainBody), "ids"); + assert.isArray(wire.ids); + assert.isNumber(wire.projection.visibleTurnItems[0].item.id); + assert.isNumber(wire.projection.thread.id); + assert.isBelow(tableBody.length, plainBody.length); + const decoded = decodeBounded(tableBody); + assert.deepStrictEqual(decoded, decodeBounded(plainBody)); + + // Only the long thread has older history to page. + if (threadId !== LONG) return; + assert.isNotNull(decoded.historyCursor); + const history = `/api/orchestration/threads/${encodeURIComponent(threadId)}/history?cursor=${encodeURIComponent(decoded.historyCursor ?? "")}`; + const plainPage = await (await get(history)).text(); + const tablePage = await (await get(`${history}&compactIds=1`)).text(); + assert.isArray(JSON.parse(tablePage).ids); + const page = decodeHistoryPage(tablePage); + assert.isAbove(page.items.length, 0); + assert.deepStrictEqual(page, decodeHistoryPage(plainPage)); + }), + ); + + it.effect("WS: the ID table is opt-in and decodes to the same snapshot", () => + Effect.gen(function* () { + const input = { threadId, acceptBoundedSnapshot: true, acceptCompactTurnItems: true }; + const plain = yield* firstSnapshot(input); + const table = yield* firstSnapshot({ ...input, acceptCompactIds: true }); + + assert.notProperty(encodeStreamItem(plain.raw), "ids"); + assert.property(encodeStreamItem(table.raw), "ids"); + assert.deepStrictEqual(table.decoded, plain.decoded); + }), + ); + it.effect("WS: bounded snapshots stay unchanged unless the client opts in", () => Effect.gen(function* () { const legacy = yield* firstSnapshot({ threadId, acceptBoundedSnapshot: true }); @@ -546,6 +594,15 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { boundedSnapshotProjection(decodeBounded(dedupedBody)), boundedSnapshotProjection(decodeBounded(compactBody)), ); + // The client sends both opt-ins: bodies restore after the ID table resolves. + const tabledBody = await ( + await get(`${base}?compactTurnItems=1&dedupeBodies=1&compactIds=1`) + ).text(); + assert.property(JSON.parse(tabledBody), "ids"); + assert.deepStrictEqual( + boundedSnapshotProjection(decodeBounded(tabledBody)), + boundedSnapshotProjection(decodeBounded(compactBody)), + ); }), ); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 818698af746d..772fa1710b9b 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -228,6 +228,7 @@ export const layer = HttpApiBuilder.group( bounded, compactTurnItems: args.query.compactTurnItems === "1", dedupeBodies: args.query.dedupeBodies === "1", + compactIds: args.query.compactIds === "1", }), }; }), @@ -252,6 +253,9 @@ export const layer = HttpApiBuilder.group( args.query.view === "conversation", ) .pipe( + Effect.map((page) => + args.query.compactIds === "1" ? { ...page, idTable: true as const } : page, + ), traceLocalHandlerWork, Effect.catch( Effect.fnUntraced(function* (cause) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6eb6febbe9e8..69204e9cf798 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -787,6 +787,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; readonly acceptDedupedBodies?: boolean; + readonly acceptCompactIds?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const dedupeBodies = input.acceptDedupedBodies === true; @@ -890,6 +891,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ...snapshot, compactTurnItems: input.acceptCompactTurnItems === true, dedupeBodies, + compactIds: input.acceptCompactIds === true, }) : (() => { const projection = projectThreadProjectionForWire(snapshot.projection); diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index 52ce11411af7..c4b52fd8d3ed 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -1,7 +1,8 @@ -import { ThreadId } from "@t3tools/contracts"; +import { OrchestrationV2ThreadBoundedSnapshot, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts"; import * as RpcHttp from "../rpc/http.ts"; @@ -117,22 +118,29 @@ const boundedBody = (compact: boolean) => ({ latestLocalTurnOrdinal: 2, ...(compact ? { turnItemsOmitLocalVisible: true } : {}), }); +const BoundedJson = Schema.toCodecJson(OrchestrationV2ThreadBoundedSnapshot); +const idTableBody = () => { + const snapshot = Schema.decodeUnknownSync(BoundedJson)(boundedBody(true)); + return Schema.encodeSync(BoundedJson)({ ...snapshot, idTable: true }); +}; describe("boundedThreadSnapshotLoader", () => { it.effect.each([ - ["an older server that ignores the opt-in", false], - ["a server that sends compact turnItems", true], - ] as const)("requests compact turnItems and loads %s", ([, compact]) => { + ["an older server that ignores the opt-in", () => boundedBody(false)], + ["a server that sends compact turnItems", () => boundedBody(true)], + ["a server that also sends an ID table", idTableBody], + ] as const)("requests compact turnItems and loads %s", ([, body]) => { const urls: string[] = []; const fetchFn = ((input: RequestInfo | URL) => { urls.push(String(input)); - return Promise.resolve(Response.json(boundedBody(compact))); + return Promise.resolve(Response.json(body())); }) satisfies typeof fetch; return Effect.gen(function* () { const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; const result = yield* loader.load(PREPARED, THREAD_ID); expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); + expect(new URL(urls[0]!).searchParams.get("compactIds")).toBe("1"); expect(result._tag).toBe("present"); if (result._tag !== "present") return; expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index 5c57eec1fafc..1d6e4af118df 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -30,7 +30,7 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( }) { const endpoint = { params: { threadId: input.threadId }, - query: { compactTurnItems: "1", dedupeBodies: "1" }, + query: { compactTurnItems: "1", dedupeBodies: "1", compactIds: "1" }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index 2d99d7dbf0e0..a9714172e44b 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -28,6 +28,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( params: { threadId: input.threadId }, query: { cursor: input.cursor, + compactIds: "1", ...(input.view === undefined ? {} : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), }, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index ef2393c0255e..3bfa1ffbb409 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -961,7 +961,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), ...(acceptBoundedSnapshot - ? { acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const } + ? { + acceptBoundedSnapshot: true as const, + acceptCompactTurnItems: true as const, + acceptCompactIds: true as const, + } : {}), acceptDedupedBodies: true as const, }; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 79723716194b..f96c5a380b25 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -4,12 +4,19 @@ import * as Schema from "effect/Schema"; import * as SchemaGetter from "effect/SchemaGetter"; import * as SchemaTransformation from "effect/SchemaTransformation"; +// Hermes copies the string on every `trim()`, even when nothing changes. +// Returning the original keeps repeated IDs shared after decode. +const trim = (value: string) => { + const trimmed = value.trim(); + return trimmed.length === value.length ? value : trimmed; +}; + export const TrimmedString = Schema.String.pipe( Schema.decodeTo( Schema.String, SchemaTransformation.transformEffect({ - decode: (value) => Effect.succeed(value.trim()), - encode: (value) => Effect.succeed(value.trim()), + decode: (value) => Effect.succeed(trim(value)), + encode: (value) => Effect.succeed(trim(value)), }), ), ); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 508d063c0216..51634d78eef9 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -674,12 +674,14 @@ const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ compactTurnItems: Schema.optionalKey(Schema.String), dedupeBodies: Schema.optionalKey(Schema.String), + compactIds: Schema.optionalKey(Schema.String), }); const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ cursor: TrimmedNonEmptyString, throughEntryId: Schema.optional(TrimmedNonEmptyString), view: Schema.optional(Schema.Literals(["conversation", "activity"])), + compactIds: Schema.optionalKey(Schema.String), }); const EnvironmentOrchestrationThreadHistoryErrors = [ diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index c193ccd943e4..2414d3cd0198 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -175,6 +175,24 @@ describe("orchestration V2 contracts", () => { if (snapshot?.kind !== "snapshot") throw new Error("expected a snapshot"); expect(snapshot.projection.turnItems.map((turnItem) => turnItem.id)).toEqual(["item-known"]); expect(snapshot.projection.visibleTurnItems.map((row) => row.item.id)).toEqual(["item-known"]); + // The same snapshot with an ID table: refs resolve before unknown items are skipped. + const tabled = (turnItem: { readonly id: string }, ref: number) => ({ ...turnItem, id: ref }); + const [tabledSnapshot] = decodeWireItems([ + { + kind: "snapshot", + snapshotSequence: 1, + projection: { + ...projection, + turnItems: [tabled(future, 0), tabled(known, 1)], + visibleTurnItems: [ + { ...projected(0, future), sourceItemId: 0, item: tabled(future, 0) }, + { ...projected(1, known), sourceItemId: 1, item: tabled(known, 1) }, + ], + }, + ids: ["item-future", "item-known"], + }, + ]); + expect(tabledSnapshot).toEqual(snapshot); expect(futureEvent).toEqual({ kind: "unknown-event", sequence: 2, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f909de7fabd9..6aa4ea7bbe66 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1,4 +1,5 @@ import { OrchestrationMessageContext } from "./composerContext.ts"; +import { withRecordIdTable } from "./orchestrationV2IdTable.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaAST from "effect/SchemaAST"; @@ -3399,9 +3400,16 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), /** The client restores `*Omitted` bodies from records it already holds. */ acceptDedupedBodies: Schema.optionalKey(Schema.Boolean), + acceptCompactIds: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2SubscribeThreadInput.Type; +/** + * Server-side only: set it on a response for a client that opted in, and the + * `withRecordIdTable` codec sends that response with an ID table. + */ +const IdTableMarker = Schema.optionalKey(Schema.Literal(true)); + export const OrchestrationV2ThreadDetailSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projection: OrchestrationV2ThreadProjection, @@ -3413,33 +3421,41 @@ export const OrchestrationV2ThreadDetailSnapshot = Schema.Struct({ export type OrchestrationV2ThreadDetailSnapshot = typeof OrchestrationV2ThreadDetailSnapshot.Type; /** Keep historyCursor opaque; resume events with afterSequence = snapshotSequence. */ -export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({ - snapshotSequence: NonNegativeInt, - projection: OrchestrationV2ThreadProjection, - historyCursor: Schema.NullOr(TrimmedNonEmptyString), - hasMoreHistory: Schema.Boolean, - latestLocalTurnOrdinal: Schema.NullOr(NonNegativeInt), - /** - * Set only for clients that opted in: `projection.turnItems` omits the items - * of local visible rows, which lead the full list. Clients must restore them - * with `boundedSnapshotProjection` before using the projection. - */ - turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), -}); +export const OrchestrationV2ThreadBoundedSnapshot = withRecordIdTable( + Schema.Struct({ + snapshotSequence: NonNegativeInt, + projection: OrchestrationV2ThreadProjection, + historyCursor: Schema.NullOr(TrimmedNonEmptyString), + hasMoreHistory: Schema.Boolean, + latestLocalTurnOrdinal: Schema.NullOr(NonNegativeInt), + /** + * Set only for clients that opted in: `projection.turnItems` omits the items + * of local visible rows, which lead the full list. Clients must restore them + * with `boundedSnapshotProjection` before using the projection. + */ + turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), + idTable: IdTableMarker, + }), + () => "projection", +); export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type; -export const OrchestrationV2ThreadHistoryPage = Schema.Struct({ - snapshotSequence: NonNegativeInt, - items: projectedTurnItemArray( - OrchestrationV2ProjectedTurnItem, - OrchestrationV2ProjectedTurnItem.mapFields((fields) => ({ - ...fields, - item: ForwardCompatibleUnion(OrchestrationV2TurnItem.members, "type"), - })), - ), - nextCursor: Schema.NullOr(TrimmedNonEmptyString), - hasMoreHistory: Schema.Boolean, -}); +export const OrchestrationV2ThreadHistoryPage = withRecordIdTable( + Schema.Struct({ + snapshotSequence: NonNegativeInt, + items: projectedTurnItemArray( + OrchestrationV2ProjectedTurnItem, + OrchestrationV2ProjectedTurnItem.mapFields((fields) => ({ + ...fields, + item: ForwardCompatibleUnion(OrchestrationV2TurnItem.members, "type"), + })), + ), + nextCursor: Schema.NullOr(TrimmedNonEmptyString), + hasMoreHistory: Schema.Boolean, + idTable: IdTableMarker, + }), + () => null, +); export type OrchestrationV2ThreadHistoryPage = typeof OrchestrationV2ThreadHistoryPage.Type; const knownDomainEventTypes: ReadonlySet = new Set( @@ -3486,26 +3502,30 @@ const OrchestrationV2UnknownThreadStreamEvent = Schema.Struct({ ), ); -export const OrchestrationV2ThreadStreamItem = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("synchronized"), - }), - Schema.Struct({ - kind: Schema.Literal("snapshot"), - snapshotSequence: NonNegativeInt, - projection: OrchestrationV2ThreadProjection, - historyCursor: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), - hasMoreHistory: Schema.optionalKey(Schema.Boolean), - latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)), - turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), - }), - Schema.Struct({ - kind: Schema.Literal("event"), - sequence: NonNegativeInt, - event: OrchestrationV2DomainEvent, - }), - OrchestrationV2UnknownThreadStreamEvent, -]); +export const OrchestrationV2ThreadStreamItem = withRecordIdTable( + Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("synchronized"), + }), + Schema.Struct({ + kind: Schema.Literal("snapshot"), + snapshotSequence: NonNegativeInt, + projection: OrchestrationV2ThreadProjection, + historyCursor: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + hasMoreHistory: Schema.optionalKey(Schema.Boolean), + latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)), + turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), + idTable: IdTableMarker, + }), + Schema.Struct({ + kind: Schema.Literal("event"), + sequence: NonNegativeInt, + event: OrchestrationV2DomainEvent, + }), + OrchestrationV2UnknownThreadStreamEvent, + ]), + (item) => (item.kind === "snapshot" ? "projection" : undefined), +); export type OrchestrationV2ThreadStreamItem = typeof OrchestrationV2ThreadStreamItem.Type; export class OrchestrationV2DispatchCommandError extends Schema.TaggedError()( diff --git a/packages/contracts/src/orchestrationV2IdTable.test.ts b/packages/contracts/src/orchestrationV2IdTable.test.ts new file mode 100644 index 000000000000..6bd2b625ce5a --- /dev/null +++ b/packages/contracts/src/orchestrationV2IdTable.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { withRecordIdTable } from "./orchestrationV2IdTable.ts"; + +const Page = withRecordIdTable( + Schema.Struct({ + rows: Schema.Array( + Schema.Struct({ + id: Schema.String, + count: Schema.Number, + parentIds: Schema.Array(Schema.String), + mergedId: Schema.optional(Schema.String), + item: Schema.Struct({ ownerId: Schema.NullOr(Schema.String), legacyId: Schema.Number }), + }), + ), + idTable: Schema.optionalKey(Schema.Literal(true)), + }), + () => null, +); +const encode = Schema.encodeSync(Schema.toCodecJson(Page)); +const decode = Schema.decodeUnknownSync(Schema.toCodecJson(Page)); + +describe("record ID table", () => { + it("interns record ID slots and restores them exactly, numbers included", () => { + const value = { + rows: [ + { id: "row:a", count: 7, parentIds: ["row:b"], item: { ownerId: "row:b", legacyId: 3 } }, + { id: "row:b", count: 3, parentIds: [], item: { ownerId: null, legacyId: 0 } }, + ], + }; + const wire = JSON.parse(JSON.stringify(encode({ ...value, idTable: true }))); + + expect(wire).toEqual({ + rows: [ + { id: 0, count: 7, parentIds: [1], item: { ownerId: 1, legacyId: 2 } }, + { id: 1, count: 3, parentIds: [], item: { ownerId: null, legacyId: 3 } }, + ], + ids: ["row:a", "row:b", 3, 0], + }); + expect(decode(wire)).toEqual(value); + // The table is consumed, so a second decode of the same object cannot remap numbers. + expect(decode(wire)).toEqual(value); + }); + + it("rejects an index outside the table, even in an optional slot", () => { + const wire = { + rows: [{ id: 0, count: 1, parentIds: [], mergedId: 5, item: { ownerId: null, legacyId: 0 } }], + ids: ["row:a"], + }; + + expect(() => decode(wire)).toThrow(/mergedId/); + }); + + it("sends and reads the plain form without the marker", () => { + const value = { + rows: [{ id: "row:a", count: 1, parentIds: [], item: { ownerId: null, legacyId: 1 } }], + }; + + expect(encode(value)).toEqual(value); + expect(decode(value)).toEqual(value); + }); +}); diff --git a/packages/contracts/src/orchestrationV2IdTable.ts b/packages/contracts/src/orchestrationV2IdTable.ts new file mode 100644 index 000000000000..3d4205be76ac --- /dev/null +++ b/packages/contracts/src/orchestrationV2IdTable.ts @@ -0,0 +1,125 @@ +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; + +/** + * Opt-in wire form: on each record (top-level array element, top-level object, or `item`), values + * under `id`, `*Id`, and `*Ids` index the response's `ids` table. Both peers must apply this exact + * rule; changing it needs a new opt-in (`compactIds=2`, or a new `accept*` key over WS). + */ +type JsonObject = { [key: string]: unknown }; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); +const isIdKey = (key: string) => key === "id" || key.endsWith("Id"); +const isIdListKey = (key: string) => key.endsWith("Ids"); + +function internRecordIds(container: JsonObject, ids: Array): JsonObject { + const index = new Map(); + const ref = (value: unknown) => { + if (typeof value !== "string" && typeof value !== "number") return value; + let position = index.get(value); + if (position === undefined) { + position = ids.length; + ids.push(value); + index.set(value, position); + } + return position; + }; + // Copies each record: unknown members can pass through encoding by reference. + const internRecord = (record: JsonObject): JsonObject => { + const out: JsonObject = {}; + for (const key in record) { + const value = record[key]; + out[key] = isIdKey(key) + ? ref(value) + : isIdListKey(key) && Array.isArray(value) + ? value.map(ref) + : key === "item" && isObject(value) + ? internRecord(value) + : value; + } + return out; + }; + const out: JsonObject = {}; + for (const key in container) { + const value = container[key]; + out[key] = Array.isArray(value) + ? value.map((record) => (isObject(record) ? internRecord(record) : record)) + : isObject(value) + ? internRecord(value) + : value; + } + return out; +} + +// Mutates freshly parsed JSON in place; the client owns it and this avoids a copy. +function expandRecordIds(container: JsonObject, ids: ReadonlyArray): void { + const expandRecord = (record: JsonObject) => { + for (const key in record) { + const value = record[key]; + if (typeof value === "number") { + if (isIdKey(key)) record[key] = ids[value]; + } else if (Array.isArray(value)) { + if (isIdListKey(key)) { + for (let i = 0; i < value.length; i++) { + const entry = value[i]; + if (typeof entry === "number") value[i] = ids[entry]; + } + } + } else if (key === "item" && isObject(value)) { + expandRecord(value); + } + } + }; + for (const key in container) { + const value = container[key]; + if (Array.isArray(value)) { + for (const record of value) if (isObject(record)) expandRecord(record); + } else if (isObject(value)) { + expandRecord(value); + } + } +} + +// `toCodecJson` turns `Schema.Unknown` into a recursive JSON check of the whole +// tree. The input is parsed JSON and the inner codec validates it anyway. +const ParsedJson = Schema.declare((_: unknown): _ is unknown => true, { + toCodecJson: () => undefined, +}); + +/** + * Adds the optional ID table to a snapshot-shaped schema. `container` returns the + * key whose value holds the records, `null` when records sit at the top level, or + * `undefined` when the value has no records (other stream item kinds). + */ +export const withRecordIdTable = ( + schema: S, + container: (value: JsonObject) => string | null | undefined, +) => { + const codec = Schema.toCodecJson(schema); + type Encoded = typeof codec.Encoded; + return ParsedJson.pipe( + Schema.decodeTo(codec, { + decode: SchemaGetter.transform((raw: unknown): Encoded => { + if (!isObject(raw) || !Array.isArray(raw.ids)) return raw as Encoded; + // Consumed on the parsed object itself, so decoding it again is a no-op. + const ids: ReadonlyArray = raw.ids; + delete raw.ids; + const key = container(raw); + const target = key === null ? raw : key === undefined ? undefined : raw[key]; + if (isObject(target)) expandRecordIds(target, ids); + return raw as Encoded; + }), + encode: SchemaGetter.transform((encoded: Encoded): unknown => { + if (!isObject(encoded) || encoded.idTable !== true) return encoded; + const { idTable: _idTable, ...rest } = encoded; + const key = container(rest); + const ids: Array = []; + if (key === null) return { ...internRecordIds(rest, ids), ids }; + const target = key === undefined ? undefined : rest[key]; + if (key === undefined || !isObject(target)) return rest; + return { ...rest, [key]: internRecordIds(target, ids), ids }; + }), + }), + ); +}; From e666c01aeaee14d486b32e00d938af598d59bd51 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 20:28:59 -0700 Subject: [PATCH 02/34] fix(mobile): keep visible diff files highlighted (#24682) Co-authored-by: Claude Opus 5.5 --- .../modules/t3reviewdiff/T3ReviewDiffView.kt | 16 +- .../t3reviewdiff/ReviewDiffViewportTest.kt | 61 +++- .../diffs/nativeReviewDiffHighlighter.test.ts | 92 ++++++ .../diffs/nativeReviewDiffHighlighter.ts | 2 + .../diffs/nativeReviewDiffSource.test.ts | 38 +++ .../features/diffs/nativeReviewDiffSource.ts | 6 +- .../diffs/nativeReviewDiffSurface.test.tsx | 39 ++- .../review/useNativeReviewDiffBridge.ts | 2 + .../review/useNativeReviewDiffHighlighting.ts | 7 +- .../pullRequest/PullRequestService.test.ts | 266 +++++++++++++----- .../src/pullRequest/PullRequestService.ts | 28 +- 11 files changed, 465 insertions(+), 92 deletions(-) diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index a15c3ada2932..8426ddb565a4 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -33,6 +33,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private val onToggleComment by EventDispatcher() private var rows: List = emptyList() private var visibleRows: List = emptyList() + private var visibleRowIndices = IntArray(0) private var collapsedFileIds: Set = emptySet() private var viewedFileIds: Set = emptySet() private var selectedRowIds: Set = emptySet() @@ -75,8 +76,8 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont onDebug( mapOf( "message" to "visible-range", - "firstRowIndex" to first, - "lastRowIndex" to last, + "firstRowIndex" to visibleRowIndices[first], + "lastRowIndex" to visibleRowIndices[last], "totalRows" to rows.size, ), ) @@ -105,6 +106,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont horizontalScroller.forceFinished(true) rows = emptyList() visibleRows = emptyList() + visibleRowIndices = IntArray(0) canvasView.rows = emptyList() canvasView.tokensByRowId = emptyMap() lastVisibleFileId = null @@ -393,16 +395,20 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private fun rebuildVisibleRows() { canvasView.setViewportEventsPaused(true) val filtered = ArrayList(rows.size) + val indices = IntArray(rows.size) var currentFileCollapsed = false - rows.forEach { row -> + rows.forEachIndexed { index, row -> if (row.kind == "file") { currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId) - filtered.add(row) - } else if (!currentFileCollapsed) { + } + if (row.kind == "file" || !currentFileCollapsed) { + indices[filtered.size] = index filtered.add(row) } } visibleRows = filtered + // JS selects highlights from the full payload, including collapsed rows. + visibleRowIndices = indices.copyOf(filtered.size) canvasView.rows = filtered canvasView.viewedFileIds = viewedFileIds canvasView.selectedRowIds = selectedRowIds diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt index 28935a11db8d..10d9a45107c7 100644 --- a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt @@ -117,6 +117,55 @@ class ReviewDiffViewportTest { } } + @Test + fun collapsedFilesKeepViewportIndicesInTheFullPayload() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val appContext = AppContext() + val view = T3ReviewDiffView(activity, appContext) + val decoder = ManualDecoder() + view.javaClass.getDeclaredField("payloadDecodeExecutor").apply { isAccessible = true } + .set(view, decoder) + activity.setContentView(view) + val canvas = view.getChildAt(0) + val measure = View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY) + canvas.measure(measure, measure) + canvas.layout(0, 0, 320, 320) + try { + view.setRowsJson(rows("expanded-file", 100001, 100226)) + decoder.completeNext() + assertEquals(0, ranges(appContext).last()["firstRowIndex"]) + assertTrue((ranges(appContext).last()["lastRowIndex"] as Int) < 100001) + appContext.events.clear() + + view.setCollapsedFileIdsJson("[\"first-file\"]") + val collapsedRange = ranges(appContext).single() + assertEquals("the collapsed file header stays visible", 0, collapsedRange["firstRowIndex"]) + assertTrue( + "the following expanded file must be addressed in the full payload", + (collapsedRange["lastRowIndex"] as Int) > 100001 + ) + assertEquals(100226, collapsedRange["totalRows"]) + appContext.events.clear() + + view.setInitialFileId("expanded-file") + assertEquals(100001, ranges(appContext).last()["firstRowIndex"]) + assertEquals( + "expanded-file", + appContext.events.last { it.first == "onVisibleFileChange" }.second["fileId"] + ) + appContext.events.clear() + + view.setCollapsedFileIdsJson("[]") + view.setInitialFileId("first-file") + assertEquals(0, ranges(appContext).last()["firstRowIndex"]) + assertTrue((ranges(appContext).last()["lastRowIndex"] as Int) < 100001) + } finally { + view.cleanup() + activity.finish() + appContext.events.clear() + } + } + private fun rowTop(canvas: View, index: Int): Int = canvas.javaClass .getDeclaredMethod("rowTop", Int::class.javaPrimitiveType).apply { isAccessible = true } .invoke(canvas, index) as Int @@ -125,13 +174,17 @@ class ReviewDiffViewportTest { .filter { (name, payload) -> name == "onDebug" && payload["message"] == "visible-range" } .map { it.second } - private fun rows(selectedFileId: String): String = JSONArray().apply { - for (index in 0 until 1000) { + private fun rows( + selectedFileId: String, + selectedStart: Int = 900, + count: Int = 1000 + ): String = JSONArray().apply { + for (index in 0 until count) { put( JSONObject().apply { put("id", "$selectedFileId:$index") - put("kind", if (index == 0 || index == 900) "file" else "line") - put("fileId", if (index < 900) "first-file" else selectedFileId) + put("kind", if (index == 0 || index == selectedStart) "file" else "line") + put("fileId", if (index < selectedStart) "first-file" else selectedFileId) put("content", "const value = $index") } ) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index c418d528129a..36773f8bdc4f 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -131,6 +131,98 @@ describe("highlightNativeReviewDiffVisibleRows", () => { expect([...new Set(loadedFiles)].sort()).toEqual(["file-7", "file-8"]); }); + it("skips collapsed code before the row cap and restores it on expansion", async () => { + const collapsedSource = Array.from( + { length: 100_000 }, + (_, index) => `int hidden${index} = 1;`, + ); + const expandedSource = [ + "/* open", + ...Array.from({ length: 222 }, () => "still a comment"), + "*/ int visible = 1;", + ]; + const collapsedLoad = vi.fn(async () => ({ + oldContents: "", + newContents: collapsedSource.join("\n"), + })); + const expandedLoad = vi.fn(async () => ({ + oldContents: "", + newContents: expandedSource.join("\n"), + })); + const files = [ + { + ...TYPESCRIPT_FILE, + id: "collapsed", + language: "c", + path: "highlights.c", + loadContents: collapsedLoad, + }, + { + ...TYPESCRIPT_FILE, + id: "expanded", + language: "c", + path: "highlights.h", + loadContents: expandedLoad, + }, + ]; + const rows = [collapsedSource, expandedSource].flatMap((source, fileIndex) => { + const fileId = files[fileIndex]!.id; + return [ + { kind: "file", id: `${fileId}-header`, fileId }, + ...source.map((content, index) => ({ + ...makeLine({ + id: `${fileId}-${index}`, + content, + change: "add", + newLineNumber: index + 1, + }), + fileId, + })), + ] satisfies NativeReviewDiffRow[]; + }); + const input = { + rows, + files, + scheme: "dark" as const, + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + }; + // A collapsed header and the next expanded header can share an iOS viewport, + // with 100,000 zero-height code rows between their canonical indices. + const viewport = await highlightNativeReviewDiffVisibleRows({ + ...input, + collapsedFileIds: new Set(["collapsed"]), + overscanRows: 0, + }); + expect(Object.keys(viewport.tokensByRowId)).toEqual( + expandedSource.map((_, index) => `expanded-${index}`), + ); + const overscan = await highlightNativeReviewDiffVisibleRows({ + ...input, + collapsedFileIds: new Set(["collapsed"]), + lastRowIndex: 100_002, + alreadyHighlightedRowIds: new Set(["expanded-0"]), + }); + expect(Object.keys(overscan.tokensByRowId)).toEqual( + Array.from({ length: 160 }, (_, index) => `expanded-${index + 1}`), + ); + expect(overscan.tokensByRowId["expanded-1"]).toEqual( + highlightCode(expandedSource.join("\n"), "c", "dark")[1], + ); + expect(collapsedLoad).not.toHaveBeenCalled(); + expect(expandedLoad).toHaveBeenCalled(); + + const expanded = await highlightNativeReviewDiffVisibleRows({ + ...input, + lastRowIndex: 400, + collapsedFileIds: new Set(), + overscanRows: 0, + }); + expect(expanded.rowCount).toBe(360); + expect(expanded.tokensByRowId["collapsed-0"]).toBeDefined(); + expect(collapsedLoad).toHaveBeenCalledTimes(1); + }); + it("delivers the dominant viewport file before fetching a smaller blocked neighbor", async () => { const neighborStarted = Promise.withResolvers(); const neighborResponse = Promise.withResolvers<{ oldContents: string; newContents: string }>(); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index a4084d2ccaa1..f25d058995a4 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -18,6 +18,7 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly overscanRows?: number; readonly maxRows?: number; readonly alreadyHighlightedRowIds?: ReadonlySet; + readonly collapsedFileIds?: ReadonlySet; readonly signal?: AbortSignal; readonly onChunk?: (chunk: NativeReviewDiffHighlightChunk) => void; } @@ -290,6 +291,7 @@ export async function highlightNativeReviewDiffVisibleRows( row && isHighlightableLineRow(row) && visibleFileIds.has(row.fileId) && + !input.collapsedFileIds?.has(row.fileId) && !input.alreadyHighlightedRowIds?.has(row.id) && fileMap.has(row.fileId) ) { diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts index 3dcc3c7597f0..ef2743a34d58 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts @@ -1,6 +1,7 @@ import { expect, it, vi } from "vite-plus/test"; import { createNativeReviewDiffSourceLoader } from "./nativeReviewDiffSource"; +import * as DiffHighlightTrace from "./nativeReviewDiffTrace"; import type { NativeReviewDiffSource } from "./nativeReviewDiffTypes"; const SOURCE = { oldContents: "const before = true;", newContents: "const after = false;" }; @@ -149,3 +150,40 @@ it("serializes RPCs and drops cancelled queued files", async () => { await load("next.ts"); expect(fetch.mock.calls).toEqual([["active.ts"], ["next.ts"]]); }); + +it("starts a shared fresh request for a file whose cancelled request was dropped", async () => { + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + const dropped = Promise.withResolvers(); + const replacementStarted = Promise.withResolvers(); + const replacementResponse = Promise.withResolvers(); + using _trace = vi.spyOn(DiffHighlightTrace, "traceDiffHighlight").mockImplementation((event) => { + if (event === "source-dropped") dropped.resolve(); + }); + const fetch = vi.fn((file: string) => { + if (file === "active.ts") { + started.resolve(); + return response.promise; + } + replacementStarted.resolve(); + return replacementResponse.promise; + }); + const load = createNativeReviewDiffSourceLoader(fetch); + const active = load("active.ts"); + await started.promise; + const controller = new AbortController(); + const queued = load("cancelled.ts", controller.signal); + controller.abort(); + expect(await queued).toBeNull(); + response.resolve(SOURCE); + await dropped.promise; + const fresh = load("cancelled.ts"); + // Settling first means the fresh caller joined the dropped request instead of fetching. + await Promise.race([replacementStarted.promise, fresh]); + const shared = load("cancelled.ts"); + replacementResponse.resolve(SOURCE); + expect(await fresh).toBe(SOURCE); + expect(await shared).toBe(SOURCE); + expect(await active).toBe(SOURCE); + expect(fetch.mock.calls).toEqual([["active.ts"], ["cancelled.ts"]]); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts index 52fae25d35b9..8dc30c718253 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts @@ -114,6 +114,8 @@ export function createNativeReviewDiffSourceLoader( const promise = tail .then(async () => { if ([...subscribers].every((subscriber) => subscriber?.aborted)) { + // A cancelled request must stop being shareable before its promise settles. + pending.delete(fileId); traceDiffHighlight("source-dropped", { loader, file }); return null; } @@ -169,7 +171,9 @@ export function createNativeReviewDiffSourceLoader( throw error; } }) - .finally(() => pending.delete(fileId)); + .finally(() => { + if (pending.get(fileId) === request) pending.delete(fileId); + }); tail = promise.then( () => {}, () => {}, diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx index 52cc18053427..81d43353d4c1 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx @@ -192,13 +192,14 @@ function setup(integrated = false) { rowIdByCommentLineId: new Map(), }; const ids: string[] = []; + let collapsedFileIds: ReadonlyArray = ids; function PullRequestDiff({ targetFileId }: { targetFileId?: string }) { const bridge = useNativeReviewDiffBridge({ threadKey: null, sectionId: "pull-request:test:1:all", diff: "test diff", data, - collapsedFileIds: ids, + collapsedFileIds, viewedFileIds: ids, selectedRowIds: ids, canHighlight: true, @@ -247,6 +248,10 @@ function setup(integrated = false) { async mountPullRequest(targetFileId?: string) { await act(async () => root.render()); }, + async collapseFiles(fileIds: ReadonlyArray) { + collapsedFileIds = fileIds; + await act(async () => root.render()); + }, async updateComment() { data = { ...data, @@ -403,3 +408,35 @@ it("recognizes registration races for the installed native view name", async () ), ).toBe(true); }); + +it("cancels stale collapse work and highlights expansion without resetting accepted tokens", async () => { + using screen = setup(true); + await screen.mountPullRequest(); + await screen.jump(0); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0"); + + const response = Promise.withResolvers(); + const highlight = mocks.highlight.getMockImplementation()!; + mocks.highlight.mockImplementation(async (input: HighlightNativeReviewDiffVisibleRowsInput) => { + if (input.firstRowIndex === 400) { + await response.promise; + if (input.collapsedFileIds?.has("last-file")) { + return { tokensByRowId: {}, rowCount: 0, retryableRowIds: [], durationMs: 0 }; + } + } + return highlight(input); + }); + await screen.jump(400); + await screen.collapseFiles(["last-file"]); + await act(async () => response.resolve()); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0"); + + await screen.collapseFiles([]); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0,r400"); + await screen.jump(0); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0,r400"); +}); diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index d51000bb1f7d..2ae7c77e14d9 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -43,6 +43,7 @@ export function useNativeReviewDiffBridge(input: { () => createNativeReviewDiffTheme(scheme, themeId, appTheme), [appTheme, scheme, themeId], ); + const collapsedFileIdsSet = useMemo(() => new Set(collapsedFileIds), [collapsedFileIds]); const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); @@ -69,6 +70,7 @@ export function useNativeReviewDiffBridge(input: { useNativeReviewDiffHighlighting({ files: data.files, rows: data.rows, + collapsedFileIds: collapsedFileIdsSet, scheme, resetKey: tokensResetKey, enabled: canHighlight, diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 1658f8e76951..6c454d94a6b8 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -25,10 +25,11 @@ export function useNativeReviewDiffHighlighting(input: { readonly files: ReadonlyArray; readonly rows: ReadonlyArray; readonly scheme: HighlightScheme; + readonly collapsedFileIds: ReadonlySet; readonly resetKey: string; readonly enabled: boolean; }) { - const { enabled, files, resetKey, rows, scheme } = input; + const { collapsedFileIds, enabled, files, resetKey, rows, scheme } = input; const highlightedRowIdsRef = useRef>(new Set()); const viewportScope = `${enabled}:${resetKey}:${rows.length}`; const [viewport, setViewport] = useState<{ @@ -120,6 +121,7 @@ export function useNativeReviewDiffHighlighting(input: { files, rows, scheme, + collapsedFileIds, firstRowIndex: requestRange.firstRowIndex, lastRowIndex: requestRange.lastRowIndex, overscanRows, @@ -140,6 +142,7 @@ export function useNativeReviewDiffHighlighting(input: { error: squashAtomCommandFailure(settled), resetKey, scheme, + collapsedFileIds, firstRowIndex: requestRange.firstRowIndex, lastRowIndex: requestRange.lastRowIndex, }); @@ -171,7 +174,7 @@ export function useNativeReviewDiffHighlighting(input: { traceDiffHighlight("viewport-abort", { generation, reset }); abortController.abort(); }; - }, [enabled, files, resetKey, rows, scheme, viewport, viewportScope]); + }, [collapsedFileIds, enabled, files, resetKey, rows, scheme, viewport, viewportScope]); const acknowledgeTokenPatch = useCallback((payload: Readonly>) => { const pending = pendingPatchesRef.current; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index b569e7e531d3..a17347d8347f 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3981,78 +3981,204 @@ it.effect("pauses uncached activity on a host after one activity read is rate li }), ); -it.effect("expands only files the change request changes, across every slice of its diff", () => - Effect.gen(function* () { - const read: Array = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakePullRequestProvider(SourceControlProviderKind.make("github"), { - getDiff: (input) => - Effect.succeed( - input.cursor === undefined - ? { - patch: [ - "diff --git a/src/old.ts b/src/new.ts", - "rename from src/old.ts", - "rename to src/new.ts", - "--- a/src/old.ts", - "+++ b/src/new.ts", - "", - ].join("\n"), - truncated: false, - nextCursor: "2", - } - : { - patch: [ - "diff --git a/gone.md b/gone.md", - "deleted file mode 100644", - "--- a/gone.md", - "+++ /dev/null", - "", - ].join("\n"), - truncated: false, - nextCursor: null, - }, - ), - getDiffFileContents: (input) => - Effect.sync(() => { - read.push(`${input.oldPath}->${input.newPath}`); - return { oldContents: "old", newContents: "new" }; - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; +it.effect( + "reuses loaded oversized diff slices to expand only files the change request changes", + () => + Effect.gen(function* () { + const read: Array = []; + const diffReads: Array = []; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "t3code", + workspaceRoot: "/a", + repository: "pingdotgg/t3code", + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: (input) => + Effect.sync(() => { + diffReads.push(input.cursor); + return input.cursor === undefined + ? { + patch: [ + "diff --git a/src/old.ts b/src/new.ts", + "rename from src/old.ts", + "rename to src/new.ts", + "--- a/src/old.ts", + "+++ b/src/new.ts", + `+${"x".repeat(300_000)}`, + "", + ].join("\n"), + truncated: false, + nextCursor: "2", + } + : { + patch: [ + "diff --git a/gone.md b/gone.md", + "deleted file mode 100644", + "--- a/gone.md", + "+++ /dev/null", + `-${"x".repeat(300_000)}`, + "", + ].join("\n"), + truncated: false, + nextCursor: null, + }; + }), + getDiffFileContents: (input) => + Effect.sync(() => { + read.push(`${input.oldPath}->${input.newPath}`); + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - yield* service.diffFileContents({ - ...ref, - changeType: "rename-changed", - oldPath: "src/old.ts", - newPath: "src/new.ts", - }); - yield* service.diffFileContents({ - ...ref, - changeType: "deleted", - oldPath: "gone.md", - newPath: "gone.md", - }); - const outside = yield* service - .diffFileContents({ ...ref, changeType: "deleted", oldPath: ".env", newPath: ".env" }) - .pipe(Effect.flip); - const halfRename = yield* service - .diffFileContents({ + yield* service.diff(ref); + yield* service.diff({ ...ref, cursor: "2" }); + + yield* service.diffFileContents({ ...ref, - changeType: "change", - oldPath: "src/secret.ts", + changeType: "rename-changed", + oldPath: "src/old.ts", newPath: "src/new.ts", - }) - .pipe(Effect.flip); + }); + yield* service.diffFileContents({ + ...ref, + changeType: "deleted", + oldPath: "gone.md", + newPath: "gone.md", + }); + const outside = yield* service + .diffFileContents({ ...ref, changeType: "deleted", oldPath: ".env", newPath: ".env" }) + .pipe(Effect.flip); + const halfRename = yield* service + .diffFileContents({ + ...ref, + changeType: "change", + oldPath: "src/secret.ts", + newPath: "src/new.ts", + }) + .pipe(Effect.flip); + + assert.strictEqual(outside._tag, "PullRequestOperationError"); + assert.strictEqual(halfRename._tag, "PullRequestOperationError"); + assert.deepStrictEqual(read, ["src/old.ts->src/new.ts", "gone.md->gone.md"]); + assert.deepStrictEqual(diffReads, [undefined, "2"]); + }), +); - assert.strictEqual(outside._tag, "PullRequestOperationError"); - assert.strictEqual(halfRename._tag, "PullRequestOperationError"); - assert.deepStrictEqual(read, ["src/old.ts->src/new.ts", "gone.md->gone.md"]); - }), +// Refresh and expiry are covered by the in-flight test below; repository and number share the ref key. +it.effect.each(["commit", "credential"] as const)( + "does not reuse loaded diff membership after changing %s", + (boundary) => + Effect.gen(function* () { + let credential = "broad"; + let diffReads = 0; + let contentReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + withVerifiedCredential: (_, use) => + Effect.suspend(() => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: credential }), + ), + getDiff: () => + Effect.sync(() => ({ + patch: + ++diffReads === 1 + ? `diff --git a/private.ts b/private.ts\n--- a/private.ts\n+++ b/private.ts\n+${"x".repeat(300_000)}\n` + : "", + truncated: false, + nextCursor: null, + })), + getDiffFileContents: () => + Effect.sync(() => { + contentReads += 1; + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const ref = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "101", + }; + // Load at a commit and expand without one so the commit must be part of the membership key. + const loaded = boundary === "commit" ? { ...ref, commit: "a".repeat(40) } : ref; + yield* service.withRoutingCredential(loaded, service.diff(loaded)); + if (boundary === "credential") credential = "restricted"; + const input = { + ...ref, + changeType: "change" as const, + oldPath: "private.ts", + newPath: "private.ts", + }; + const error = yield* service + .withRoutingCredential(input, service.diffFileContents(input)) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(diffReads, 2); + assert.strictEqual(contentReads, 0); + }), +); + +it.effect.each(["expiry", "refresh"] as const)( + "keeps a loaded diff's original membership scope through an in-flight %s", + (boundary) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let diffReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: () => + Effect.gen(function* () { + if (++diffReads !== 1) return { patch: "", truncated: false, nextCursor: null }; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { + patch: `diff --git a/private.ts b/private.ts\n--- a/private.ts\n+++ b/private.ts\n+${"x".repeat(300_000)}\n`, + truncated: false, + nextCursor: null, + }; + }), + getDiffFileContents: () => Effect.die("an expired file must not be read"), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const loading = yield* Effect.forkChild(service.diff(ref)); + yield* Deferred.await(started); + if (boundary === "expiry") yield* TestClock.adjust("61 seconds"); + else yield* service.invalidate({ reference: ref }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(loading); + const error = yield* service + .diffFileContents({ + ...ref, + changeType: "change", + oldPath: "private.ts", + newPath: "private.ts", + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(diffReads, 2); + }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 5a31ced21370..437f8ab4e52d 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -3202,7 +3202,14 @@ export const make = Effect.gen(function* () { ...refOfCacheKey(reference), ...(cursor === null ? {} : { cursor }), ...(commit === null ? {} : { commit }), - } as PullRequestDiffInput); + } as PullRequestDiffInput).pipe( + observeRead, + Effect.map(({ value, observedAt }) => { + // Keep the loader's epoch and read time if a refresh occurs during the provider read. + diffFiles.record(JSON.stringify([reference, cursor, commit]), value, observedAt); + return value; + }), + ); }, { capacity: DIFF_CACHE_CAPACITY, @@ -3468,14 +3475,9 @@ export const make = Effect.gen(function* () { const fileKey = (oldPath: string, newPath: string) => JSON.stringify([oldPath, newPath]); const sliceKey = (input: PullRequestDiffInput) => JSON.stringify([refCacheKey(input), input.cursor ?? null, input.commit ?? null]); - const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { - const key = sliceKey(input); - const now = yield* Clock.currentTimeMillis; - const cached = held.get(key); - if (cached !== undefined && now - cached.at <= ttlMs) return cached; - const slice = yield* diff(input); + const record = (key: string, slice: PullRequestDiffResult, at: number) => { const entry = { - at: now, + at, files: new Set( parseDiffFilePaths(slice.patch).map((file) => fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), @@ -3490,8 +3492,16 @@ export const make = Effect.gen(function* () { } held.set(key, entry); return entry; + }; + const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { + const key = sliceKey(input); + const now = yield* Clock.currentTimeMillis; + const cached = held.get(key); + if (cached !== undefined && now - cached.at <= ttlMs) return cached; + const slice = yield* diff(input); + return record(key, slice, now); }); - return { read: credentialCached(read), fileKey }; + return { read: credentialCached(read), record, fileKey }; })(); const changesFile = (input: PullRequestDiffFileContentsInput) => { const wanted = diffFiles.fileKey(input.oldPath, input.newPath); From 8fdaf3479cbac39443492386313da7e4680ae3a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 20:32:05 -0700 Subject: [PATCH 03/34] fix(mobile): Settings split view keeps its selection and its header matches the page (#24644) Co-authored-by: Claude Opus 5.5 --- .../src/components/ui/ScreenScrollView.tsx | 8 ++++++ .../features/settings/SettingsRouteScreen.tsx | 18 +++++++++---- .../settings/components/SettingsScreen.tsx | 26 ++++++++++++------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/components/ui/ScreenScrollView.tsx b/apps/mobile/src/components/ui/ScreenScrollView.tsx index 684d2e3bdf51..5e48866c1633 100644 --- a/apps/mobile/src/components/ui/ScreenScrollView.tsx +++ b/apps/mobile/src/components/ui/ScreenScrollView.tsx @@ -1,14 +1,22 @@ import { createContext, use, type ComponentProps } from "react"; import { Platform } from "react-native"; +import { cn } from "../../lib/cn"; import { NativeScrollView as ScrollView } from "../../native/NativeScrollViewMarker"; export const ScreenScrollViewBottomInset = createContext(0); +/** + * The screen's surface class. UIKit's scroll-edge material under the header takes the scroll + * view's own color; a clear scroll view gets the system's near-white bar over the screen's surface. + */ +export const ScreenScrollViewSurface = createContext(undefined); export function ScreenScrollView(props: ComponentProps) { const bottomInset = use(ScreenScrollViewBottomInset); + const surface = use(ScreenScrollViewSurface); return ( 0 ? { ...props.contentInset, bottom: (props.contentInset?.bottom ?? 0) + bottomInset } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b39e1cdde210..826d352fb4a6 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -8,6 +8,8 @@ import { useNavigationTabOrder, useHiddenNavigationTabs, } from "../workspace/navigation-tabs"; +import { currentWorkspaceSelections } from "../workspace/workspace-selection-store"; +import { rememberedSettingsPage } from "../workspace/workspace-tab-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; import { ScreenScrollView as ScrollView, @@ -42,10 +44,14 @@ export function SettingsRouteScreen() { const compactTabs = useCompactWorkspaceTabs(); const { layout } = useAdaptiveWorkspaceLayout(); const inSidebar = use(NativePrimaryColumnContext) !== null && layout.usesSplitView; - // The page open beside the list: the route right above the Settings tab's route. + // The page open beside the list: the route right above the Settings tab's route. While + // another tab is open this list stays mounted, hidden, and must keep showing the page Settings + // reopens on; otherwise returning shows it unselected for a frame before the page arrives. const selected = useNavigationState((state) => { const list = state.routes.findIndex((route) => route.name === "Settings"); - return list < 0 ? null : (state.routes[list + 1]?.name ?? null); + return list < 0 + ? rememberedSettingsPage(currentWorkspaceSelections()) + : (state.routes[list + 1]?.name ?? null); }); const sidebar = useMemo(() => (inSidebar ? { selected } : null), [inSidebar, selected]); const content = hasCloudPublicConfig() ? ( @@ -56,7 +62,7 @@ export function SettingsRouteScreen() { return ( - + {Platform.OS === "android" ? ( @@ -75,7 +81,8 @@ export function SettingsRouteScreen() { function ConfiguredSettingsRouteScreen() { // UIKit's scroll-edge material takes the scroll view's own color; like the chat list, match // the sidebar's surface so the bar doesn't darken over content at rest. - const listSurface = use(NativePrimaryColumnContext) ? "bg-drawer" : "bg-screen"; + // Grouped cards only lift off the screen surface; the drawer is as dark as they are. + const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); @@ -124,7 +131,8 @@ function ConfiguredSettingsRouteScreen() { function LocalSettingsRouteScreen() { // UIKit's scroll-edge material takes the scroll view's own color; like the chat list, match // the sidebar's surface so the bar doesn't darken over content at rest. - const listSurface = use(NativePrimaryColumnContext) ? "bg-drawer" : "bg-screen"; + // Grouped cards only lift off the screen surface; the drawer is as dark as they are. + const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; const insets = useSafeAreaInsets(); const { savedConnectionsById } = useSavedRemoteConnections(); const environmentCount = Object.keys(savedConnectionsById).length; diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index e05d71239ac6..85a8258187a1 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -9,7 +9,10 @@ import { useWorkspaceTabs } from "../../workspace/WorkspaceTabsContext"; import { useWorkspaceSurface } from "../../workspace/useWorkspaceSurface"; import { useDeviceType } from "../../../native/native-layout-metrics"; import { useCompactWorkspaceTabs } from "../../workspace/useSystemWorkspaceTabBar"; -import { ScreenScrollViewBottomInset } from "../../../components/ui/ScreenScrollView"; +import { + ScreenScrollViewBottomInset, + ScreenScrollViewSurface, +} from "../../../components/ui/ScreenScrollView"; export { SettingsScreenContent }; @@ -32,11 +35,12 @@ export function SettingsScreen( props.workspaceRoot || (Platform.OS === "android" && workspace !== null && route.name === "Settings"); + // Settings' grouped cards only lift off the screen surface, so the folded Duo doesn't take + // the workspace's drawer surface here. + const surfaceClassName = cover ? "bg-screen" : workspaceRoot ? surface.className : "bg-sheet"; + return ( - + - {props.formSheet ? ( - props.children - ) : ( - {props.children} - )} + + {props.formSheet ? ( + props.children + ) : ( + {props.children} + )} + ); From 90c455ff8fceef426bafc64c0b755ef864a353ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 20:32:06 -0700 Subject: [PATCH 04/34] fix(mobile): every Duo header blurs under the vertical bar, from one place (#24671) Co-authored-by: Claude Opus 5.5 --- .../ios/T3NativeControlsModule.swift | 4 + .../ios/T3ScrollEdgeBleedView.swift | 125 ++++++++++++++++++ apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../PullRequestReviewersScreen.tsx | 2 +- .../pull-requests/PullRequestsScreen.tsx | 2 +- .../features/threads/ThreadRouteScreen.tsx | 3 +- .../src/features/usage/UsageLimitsPooled.tsx | 3 +- .../src/native/NativeScrollViewMarker.tsx | 25 +++- docs/internals/mobile-navigation.md | 7 + vite.config.ts | 6 + 10 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 3f9b0a6f0da6..d78fa318051e 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -46,6 +46,10 @@ public final class T3NativeControlsModule: Module { Events("onMetricsChange") } + View(T3ScrollEdgeBleedView.self) { + ViewName("ScrollEdgeBleed") + } + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift new file mode 100644 index 000000000000..188869088d4f --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift @@ -0,0 +1,125 @@ +import ExpoModulesCore +import UIKit + +/// Wraps a screen's header scroll view. Where a column pads its content away from a side safe +/// area (the iPhone Duo's vertical bar), this widens the scroll view under that band. UIKit draws +/// the header's scroll-edge effect inside the scroll view, so the blur reaches the bar like in the +/// system apps while the content stays in the safe area. Everything happens in UIKit layout, so +/// folds move the blur and the content together. +final class T3ScrollEdgeBleedView: ExpoView { + // Fabric recycles scroll views, so everything changed here is put back before this view leaves + // the window, which unmounting does before any recycling. + private var scrollView: UIScrollView? + private var container: UIView? + private var bleed = UIEdgeInsets.zero + /// React Native's clipping, saved while bleeding. React Native only rewrites it when the + /// `overflow` style changes, so it stays off while bleeding. + private var containerClipped: Bool? + + deinit { + MainActor.assumeIsolated { detach() } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { detach() } else { setNeedsLayout() } + } + + override func didAddSubview(_ subview: UIView) { + super.didAddSubview(subview) + setNeedsLayout() + } + + override func willRemoveSubview(_ subview: UIView) { + if let scrollView, scrollView.isDescendant(of: subview) { detach() } + super.willRemoveSubview(subview) + } + + override func layoutSubviews() { + super.layoutSubviews() + guard window != nil, let found = findScrollView(), let parent = found.superview else { return } + if found !== scrollView { + detach() + scrollView = found + container = parent + } + bleed = flushSafeAreaBand() + apply() + } + + /// The side safe areas this view sits flush against: an ancestor padded its content away from + /// them. Only those bands are bled into, so a screen with its own margins is left alone. + private func flushSafeAreaBand() -> UIEdgeInsets { + var ancestor = superview + while let view = ancestor { + let insets = view.safeAreaInsets + if insets.left > 0 || insets.right > 0 { + let frame = convert(bounds, to: view) + let left = insets.left > 0 && abs(frame.minX - insets.left) < 0.5 ? insets.left : 0 + let right = insets.right > 0 && abs(view.bounds.maxX - insets.right - frame.maxX) < 0.5 + ? insets.right : 0 + return UIEdgeInsets(top: 0, left: left, bottom: 0, right: right) + } + ancestor = view.superview + } + return .zero + } + + /// Widening to the right leaves the content in place. Widening to the left moves it with the + /// frame, so a left inset and offset put it back. + private func apply() { + guard let scrollView, let container else { return } + // React Native sizes the scroll view's component view; the UIScrollView inside follows it by + // autoresizing, so the widened frame survives React Native's own layout updates. + let frame = container.bounds.inset(by: UIEdgeInsets(top: 0, left: -bleed.left, bottom: 0, right: -bleed.right)) + if scrollView.frame != frame { scrollView.frame = frame } + if bleed == .zero { + if let clipped = containerClipped { + containerClipped = nil + container.layer.masksToBounds = clipped + } + } else if container.layer.masksToBounds { + // The component view clips to its own bounds, which would cut the widened scroll view back. + // The scroll view itself still clips its content. + containerClipped = containerClipped ?? true + container.layer.masksToBounds = false + } + var inset = scrollView.contentInset + if inset.left != bleed.left { + inset.left = bleed.left + scrollView.contentInset = inset + } + var indicators = scrollView.verticalScrollIndicatorInsets + if indicators.right != bleed.right { + indicators.right = bleed.right + scrollView.verticalScrollIndicatorInsets = indicators + } + let x = -scrollView.adjustedContentInset.left + if scrollView.contentOffset.x != x { + scrollView.contentOffset = CGPoint(x: x, y: scrollView.contentOffset.y) + } + } + + private func detach() { + guard scrollView != nil else { return } + bleed = .zero + apply() + scrollView = nil + container = nil + } + + /// The scroll view sits a fixed few levels down (marker, React Native's scroll view component); + /// never walk into a scroll view's content. + private func findScrollView() -> UIScrollView? { + var level: [UIView] = subviews + for _ in 0..<4 { + var next: [UIView] = [] + for view in level { + if let scrollView = view as? UIScrollView { return scrollView } + next.append(contentsOf: view.subviews) + } + level = next + } + return nil + } +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e5d62df7c9e8..7adf6f0e8a5e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -27,7 +27,7 @@ import { type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; -import { ScrollViewMarker } from "react-native-screens"; +import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx index 71cbf7f9b761..1fa5cd83b874 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx @@ -17,7 +17,7 @@ import { type ScrollViewProps, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ScrollViewMarker } from "react-native-screens"; +import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { Button } from "../../components/ui/Button"; diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index e722f052b261..6c48ceb446e9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -16,7 +16,7 @@ import { View, type ScrollViewProps, } from "react-native"; -import { ScrollViewMarker } from "react-native-screens"; +import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d120c9a544c4..851fe0fb7ceb 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -28,7 +28,8 @@ import { projectScriptTerminalInput, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; -import { Platform, ScrollView } from "react-native"; +import { Platform } from "react-native"; +import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useConnectionsReady } from "../../state/workspace"; import { useEnvironmentShellReadiness } from "../../state/shell"; diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index fcd927b8d326..1eec514d330f 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -15,7 +15,8 @@ import { type LimitPoolWindow, } from "@t3tools/shared/usageLimits"; import { Fragment, type ReactNode, useId, useState } from "react"; -import { Linking, Pressable, ScrollView, View } from "react-native"; +import { Linking, Pressable, View } from "react-native"; +import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; diff --git a/apps/mobile/src/native/NativeScrollViewMarker.tsx b/apps/mobile/src/native/NativeScrollViewMarker.tsx index 80b28ef7b4db..f1bd81feaf8f 100644 --- a/apps/mobile/src/native/NativeScrollViewMarker.tsx +++ b/apps/mobile/src/native/NativeScrollViewMarker.tsx @@ -1,5 +1,7 @@ +import { requireNativeView, requireOptionalNativeModule } from "expo"; import { createContext, use, type ComponentProps } from "react"; -import { Platform, ScrollView } from "react-native"; +import { Platform, ScrollView, type ViewProps } from "react-native"; +// oxlint-disable-next-line eslint/no-restricted-imports -- The wrapper every screen must use. import { ScrollViewMarker } from "react-native-screens"; import { nativeHeaderScrollEdgeEffects } from "./scrollEdgeEffects"; @@ -9,14 +11,33 @@ const DEFAULT_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, P export const NativeScrollEdgeEffectsContext = createContext(undefined); +function resolveScrollEdgeBleed() { + try { + const module = requireOptionalNativeModule<{ + readonly ViewPrototypes?: { readonly T3NativeControls_ScrollEdgeBleed?: unknown }; + }>("T3NativeControls"); + if (!module?.ViewPrototypes?.T3NativeControls_ScrollEdgeBleed) return null; + return requireNativeView("T3NativeControls", "ScrollEdgeBleed"); + } catch { + return null; + } +} +// Widens the scroll view under the side safe areas its column pads away (the Duo's vertical bar), +// so the header's scroll-edge blur, which UIKit draws inside the scroll view, reaches the bar +// while the content stays in the safe area. Every header scroll view comes through here. +const ScrollEdgeBleed = Platform.OS === "ios" ? resolveScrollEdgeBleed() : null; + +/** Registers a screen's scroll view with its header. The only way to do so: see the lint rule. */ export function NativeScrollViewMarker(props: ComponentProps) { const effects = use(NativeScrollEdgeEffectsContext); - return ( + const marker = ( ); + return ScrollEdgeBleed ? {marker} : marker; } export function NativeScrollView(props: ComponentProps) { diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index 94bd550d142d..0718effe7b46 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -23,6 +23,13 @@ preserves behavior that is easy to break when changing native headers: - A horizontal ScrollView at its leading edge must yield to the full-screen back gesture. Upstream gives every horizontal ScrollView priority, so swiping back on a code block or table can only bounce its content. +- UIKit draws the header's scroll-edge blur inside the scroll view, and workspace + columns pad their content away from the iPhone Duo's vertical bar. Register a + header scroll view only through + [`NativeScrollViewMarker`](../../apps/mobile/src/native/NativeScrollViewMarker.tsx) + or `NativeScrollView`: their native wrapper widens the scroll view under that + band and insets its content by the same amount, so the blur reaches the bar and + the content does not. Lint rejects `ScrollViewMarker` imported anywhere else. The patch adds native header props that require code generation and a new binary. Patch rebuild guidance lives in the diff --git a/vite.config.ts b/vite.config.ts index 25fbe4e6f9a6..b8ee4011ca64 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,6 +39,12 @@ const RESTRICTED_IMPORT_PATHS = [ importNames: ["CodeView"], message: "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.", }, + { + name: "react-native-screens", + importNames: ["ScrollViewMarker"], + message: + "Use NativeScrollViewMarker or NativeScrollView (src/native/NativeScrollViewMarker.tsx): they carry the header's scroll-edge blur under the Duo's vertical bar.", + }, ]; const RESTRICTED_UI_VARIANT_PATTERNS = [ From b808f7fc6de810aa72b7131acbd3f947c6321b9f Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 25 Oct 2026 04:03:02 +0000 Subject: [PATCH 05/34] feat(settings): follow-ups steer the running turn by default (#24718) --- apps/mobile/src/lib/followUpBehavior.ts | 2 +- packages/contracts/src/settings.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/followUpBehavior.ts b/apps/mobile/src/lib/followUpBehavior.ts index 807e20bfa4fb..83a00bfed43f 100644 --- a/apps/mobile/src/lib/followUpBehavior.ts +++ b/apps/mobile/src/lib/followUpBehavior.ts @@ -2,4 +2,4 @@ import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/com export type FollowUpBehavior = Extract; -export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = "queue"; +export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = "steer"; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dfc328657efb..e47ea801f3ee 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -431,7 +431,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("enter")), ), followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( - Schema.withDecodingDefault(Effect.succeed("queue")), + Schema.withDecodingDefault(Effect.succeed("steer")), ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), From a0e0f8a80ae4caa6c32d3306a10a284e252c88d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 21:20:42 -0700 Subject: [PATCH 06/34] fix(mobile): Duo threads no longer open shifted left (#24732) --- .../ios/T3ScrollEdgeBleedView.swift | 82 ++++++++++++------- docs/internals/mobile-navigation.md | 8 +- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift index 188869088d4f..642a87f14e85 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift @@ -1,8 +1,8 @@ import ExpoModulesCore import UIKit -/// Wraps a screen's header scroll view. Where a column pads its content away from a side safe -/// area (the iPhone Duo's vertical bar), this widens the scroll view under that band. UIKit draws +/// Wraps a screen's header scroll view. Where a column pads its content away from the iPhone Duo's +/// vertical bar, this widens the scroll view under the bar. UIKit draws /// the header's scroll-edge effect inside the scroll view, so the blur reaches the bar like in the /// system apps while the content stays in the safe area. Everything happens in UIKit layout, so /// folds move the blur and the content together. @@ -11,11 +11,24 @@ final class T3ScrollEdgeBleedView: ExpoView { // the window, which unmounting does before any recycling. private var scrollView: UIScrollView? private var container: UIView? - private var bleed = UIEdgeInsets.zero + private var bleed: CGFloat = 0 + /// Whether the scroll view currently carries this view's changes. + private var applied = false /// React Native's clipping, saved while bleeding. React Native only rewrites it when the /// `overflow` style changes, so it stays off while bleeding. private var containerClipped: Bool? + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + #if canImport(UIKit, _version: 9127.0.85) + if #available(iOS 27.1, *) { + registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3ScrollEdgeBleedView, _: UITraitCollection) in + view.setNeedsLayout() + } + } + #endif + } + deinit { MainActor.assumeIsolated { detach() } } @@ -43,37 +56,54 @@ final class T3ScrollEdgeBleedView: ExpoView { scrollView = found container = parent } - bleed = flushSafeAreaBand() + bleed = verticalBarBleed() apply() } - /// The side safe areas this view sits flush against: an ancestor padded its content away from - /// them. Only those bands are bled into, so a screen with its own margins is left alone. - private func flushSafeAreaBand() -> UIEdgeInsets { + /// How far the vertical bar's safe area reaches past this view's right edge, when this view sits + /// flush against the padding its column added for it. Nothing else is bled into: not a sidebar + /// column overlapping this one, not a notch, and not a bar on the left, where widening would + /// move the content and need a horizontal offset React Native keeps rewriting. + private func verticalBarBleed() -> CGFloat { + guard verticalBarIsOnTheRight() else { return 0 } var ancestor = superview while let view = ancestor { - let insets = view.safeAreaInsets - if insets.left > 0 || insets.right > 0 { + let inset = view.safeAreaInsets.right + if inset > 0 { let frame = convert(bounds, to: view) - let left = insets.left > 0 && abs(frame.minX - insets.left) < 0.5 ? insets.left : 0 - let right = insets.right > 0 && abs(view.bounds.maxX - insets.right - frame.maxX) < 0.5 - ? insets.right : 0 - return UIEdgeInsets(top: 0, left: left, bottom: 0, right: right) + return abs(view.bounds.maxX - inset - frame.maxX) < 0.5 ? inset : 0 } ancestor = view.superview } - return .zero + return 0 + } + + private func verticalBarIsOnTheRight() -> Bool { + // The vertical-bar API ships in the iOS 27.1 SDK (UIKit 9127.0.85); see T3LayoutMetricsView. + #if canImport(UIKit, _version: 9127.0.85) + if #available(iOS 27.1, *) { + let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft + switch traitCollection.verticalBarEdge { + case .leading: return rtl + case .trailing: return !rtl + default: return false + } + } + #endif + return false } - /// Widening to the right leaves the content in place. Widening to the left moves it with the - /// frame, so a left inset and offset put it back. + /// Widening to the right leaves the content where React Native laid it out, so only the frame, + /// the container's clipping and the scroll indicator change; contentInset and contentOffset are + /// never touched. Nothing at all is written unless this view is bleeding or undoing its bleed. private func apply() { guard let scrollView, let container else { return } + guard bleed > 0 || applied else { return } // React Native sizes the scroll view's component view; the UIScrollView inside follows it by // autoresizing, so the widened frame survives React Native's own layout updates. - let frame = container.bounds.inset(by: UIEdgeInsets(top: 0, left: -bleed.left, bottom: 0, right: -bleed.right)) + let frame = container.bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: -bleed)) if scrollView.frame != frame { scrollView.frame = frame } - if bleed == .zero { + if bleed == 0 { if let clipped = containerClipped { containerClipped = nil container.layer.masksToBounds = clipped @@ -84,25 +114,17 @@ final class T3ScrollEdgeBleedView: ExpoView { containerClipped = containerClipped ?? true container.layer.masksToBounds = false } - var inset = scrollView.contentInset - if inset.left != bleed.left { - inset.left = bleed.left - scrollView.contentInset = inset - } var indicators = scrollView.verticalScrollIndicatorInsets - if indicators.right != bleed.right { - indicators.right = bleed.right + if indicators.right != bleed { + indicators.right = bleed scrollView.verticalScrollIndicatorInsets = indicators } - let x = -scrollView.adjustedContentInset.left - if scrollView.contentOffset.x != x { - scrollView.contentOffset = CGPoint(x: x, y: scrollView.contentOffset.y) - } + applied = bleed > 0 } private func detach() { guard scrollView != nil else { return } - bleed = .zero + bleed = 0 apply() scrollView = nil container = nil diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index 0718effe7b46..2e48739e456f 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -27,9 +27,11 @@ preserves behavior that is easy to break when changing native headers: columns pad their content away from the iPhone Duo's vertical bar. Register a header scroll view only through [`NativeScrollViewMarker`](../../apps/mobile/src/native/NativeScrollViewMarker.tsx) - or `NativeScrollView`: their native wrapper widens the scroll view under that - band and insets its content by the same amount, so the blur reaches the bar and - the content does not. Lint rejects `ScrollViewMarker` imported anywhere else. + or `NativeScrollView`: their native wrapper widens the scroll view to the right + under that bar, so the blur reaches it while the content keeps its React Native + frame. It never bleeds left: that would need a horizontal content offset, which + React Native rewrites on its own layout passes. Lint rejects `ScrollViewMarker` + imported anywhere else. The patch adds native header props that require code generation and a new binary. Patch rebuild guidance lives in the From c2845de6c451db48f8229d77b3533730fb1644ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 21:56:01 -0700 Subject: [PATCH 07/34] fix(mobile): PR links open in the app the same way they do on web (#24738) Co-authored-by: Claude Opus 5.5 --- .../pullRequestNavigation.test.ts | 28 ++++- apps/mobile/src/lib/pullRequestNavigation.ts | 42 +++---- apps/mobile/src/native/useOpenPullRequest.ts | 11 +- .../components/chat/ThreadDetailsPrRows.tsx | 2 +- apps/web/src/lib/openPullRequestLink.test.ts | 40 ------- apps/web/src/lib/openPullRequestLink.ts | 99 ++++------------ .../src/changeRequestLinks.test.ts | 108 ++++++++++++++++++ .../client-runtime/src/changeRequestLinks.ts | 92 +++++++++++++++ 8 files changed, 280 insertions(+), 142 deletions(-) delete mode 100644 apps/web/src/lib/openPullRequestLink.test.ts diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index faed80ecc134..ab07c99b6f0d 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -1,6 +1,6 @@ import { EnvironmentId, ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { nativePullRequestDestination } from "../../lib/pullRequestNavigation"; +import { nativePullRequestDestination as destination } from "../../lib/pullRequestNavigation"; const context = { environmentId: "remote", projectId: "project" }; const project = (id: string, canonicalKey: string, provider: string, remoteUrl: string) => ({ @@ -19,6 +19,14 @@ const projects = [ project("forgejo", "forgejo.test/org/repo", "forgejo", "http://forgejo.test:3000/org/repo.git"), ]; +const hostReads = () => ({ pullRequests: true, threadPullRequests: true }); +const nativePullRequestDestination = ( + url: string, + linkContext: typeof context | null, + linkProjects: typeof projects, + capabilities: Parameters[3] = hostReads, +) => destination(url, linkContext, linkProjects, capabilities); + describe("native PR entry points", () => { it("keeps a thread link in its own environment and handles renamed host path shapes", () => { expect( @@ -41,9 +49,25 @@ describe("native PR entry points", () => { ).toBe("forgejo.test:3000"); }); - it("leaves pull requests from repositories without a project to the external opener", () => { + it("opens a repository nobody has checked out under a project on its host, as web does", () => { expect( nativePullRequestDestination("https://github.com/someone/else/pull/12", context, projects), + ).toEqual({ ...context, host: "github.com", repository: "someone/else", number: 12 }); + }); + + it("leaves links to the external opener when the environment cannot show them", () => { + const url = "https://github.com/org/repo/pull/12"; + expect(nativePullRequestDestination(url, context, projects, () => undefined)).toBeNull(); + expect( + nativePullRequestDestination( + "https://github.com/someone/else/pull/12", + context, + projects, + () => ({ + pullRequests: true, + threadPullRequests: false, + }), + ), ).toBeNull(); }); diff --git a/apps/mobile/src/lib/pullRequestNavigation.ts b/apps/mobile/src/lib/pullRequestNavigation.ts index 1c84cfb6876d..9ce9408cf9c6 100644 --- a/apps/mobile/src/lib/pullRequestNavigation.ts +++ b/apps/mobile/src/lib/pullRequestNavigation.ts @@ -1,32 +1,32 @@ -import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import { + resolveChangeRequestLinkTarget, + type ChangeRequestLinkCapabilities, +} from "@t3tools/client-runtime/change-request-links"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; -/** - * Opens a change request in the app only under a project of the link's environment whose own - * repository holds it, preferring the one the link came from; anything else is the browser's. - */ +/** The PullRequest route for a change request link from a project, or null for the browser. */ export function nativePullRequestDestination( url: string, context: { environmentId: string; projectId: string } | null, projects: ReadonlyArray>, + capabilities: (environmentId: EnvironmentId) => ChangeRequestLinkCapabilities | undefined, ) { - const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; - if (!parsed || !context) return null; - const candidates = projects.filter((project) => project.environmentId === context.environmentId); - const project = findProjectForChangeRequest( - [ - ...candidates.filter((candidate) => candidate.id === context.projectId), - ...candidates.filter((candidate) => candidate.id !== context.projectId), - ], - parsed, - ); - if (project === undefined) return null; + if (!context) return null; + const target = resolveChangeRequestLinkTarget({ + url, + projects, + capabilities, + environmentId: EnvironmentId.make(context.environmentId), + projectId: ProjectId.make(context.projectId), + lendHostProject: true, + }); + if (target === null) return null; return { environmentId: context.environmentId, - projectId: String(project.id), - repository: parsed.repository, - number: parsed.number, - host: parsed.authority ?? parsed.host, + projectId: String(target.project.id), + repository: target.repository, + number: target.number, + ...(target.host === undefined ? {} : { host: target.host }), }; } diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index ffd5248123c9..4e71aa5fde14 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -6,6 +6,8 @@ import { resetAndroidWorkspace } from "../lib/android-workspace-navigation"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useProjects, useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; +import { appAtomRegistry } from "../state/atom-registry"; +import { environmentServerConfigsAtom } from "../state/server"; type PullRequestContext = { readonly environmentId: string; readonly projectId: string }; @@ -16,7 +18,14 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean const replace = options?.replace; return useCallback( async (url: string, context: PullRequestContext | null) => { - const params = nativePullRequestDestination(url, context, projects); + // Read at press time so a capability push does not hand every link a new handler. + const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); + const params = nativePullRequestDestination( + url, + context, + projects, + (environmentId) => serverConfigs.get(environmentId)?.environment.capabilities, + ); if (!params) return tryOpenExternalUrl(url, "pull-request"); if (Platform.OS === "android") resetAndroidWorkspace(navigation, { name: "PullRequest", params }); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index f08251b18425..c7b30050892f 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -10,7 +10,7 @@ import { Minus, Plus } from "lucide"; import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } from "react"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { findProjectOnChangeRequestHost } from "~/lib/openPullRequestLink"; +import { findProjectOnChangeRequestHost } from "@t3tools/client-runtime/change-request-links"; import { useProjects } from "~/state/entities"; import { threadEnvironment } from "~/state/threads"; diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts deleted file mode 100644 index 4b0de9244c54..000000000000 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { findProjectOnChangeRequestHost } from "./openPullRequestLink"; - -describe("findProjectOnChangeRequestHost", () => { - const project = (id: string, identity: Record) => - ({ id, repositoryIdentity: identity }) as never; - const frontend = project("frontend", { - canonicalKey: "github.com/acme/frontend", - provider: "github", - owner: "acme", - name: "frontend", - }); - const backend = project("backend", { - canonicalKey: "github.com/acme/backend", - provider: "github", - owner: "acme", - name: "backend", - }); - - it("prefers the project checked out from the link's own repository", () => { - expect( - findProjectOnChangeRequestHost([frontend, backend], { - host: "github.com", - repository: "acme/backend", - number: 7, - }), - ).toBe(backend); - }); - - it("lends any project on the host to a repository nobody has checked out", () => { - expect( - findProjectOnChangeRequestHost([frontend], { - host: "github.com", - repository: "acme/backend", - number: 7, - }), - ).toBe(frontend); - }); -}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 670484f0bdf2..b6928a805d3f 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -4,19 +4,14 @@ import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent } from "react"; import { - findProjectForChangeRequest, + resolveChangeRequestLinkTarget, resolvePullRequestPreviewTarget, } from "@t3tools/client-runtime/change-request-links"; -import { - sourceControlClients, - type ChangeRequestLink, -} from "@t3tools/client-runtime/source-control-clients"; import { useOpenLink } from "../browser/useOpenLink"; import { useClientSettings } from "../hooks/useSettings"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useRightPanelStore } from "../rightPanelStore"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { readProjects, useProjects } from "../state/entities"; @@ -34,21 +29,6 @@ export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, }); } -export function findProjectOnChangeRequestHost( - projects: ReadonlyArray, - link: ChangeRequestLink, -): EnvironmentProject | undefined { - const own = findProjectForChangeRequest(projects, link); - if (own !== undefined) return own; - return projects.find((project) => { - const identity = project.repositoryIdentity; - return ( - identity?.provider !== undefined && - sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link) - ); - }); -} - function shouldOpenPullRequestExternally( event: Pick, "metaKey" | "ctrlKey"> & Partial, "button">>, @@ -77,47 +57,27 @@ export function useOpenChangeRequestLink( // subscribing would hand every rendered link a new handler. Projects likewise, so a // link can resolve against a snapshot that arrived after this handler was built. const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); - const allProjects = readProjects(); const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; - const parsed = sourceControlClients.parseChangeRequestUrl(targetUrl)?.link; - if (parsed === undefined) return false; - const reads = (environmentId: string) => - serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === - true; - // Matching remotes do not make project ids usable outside the panel thread's environment. - const projects = resolvedThreadRef - ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId) - : targetEnvironmentId - ? allProjects.filter((project) => project.environmentId === targetEnvironmentId) - : allProjects - .filter((project) => reads(project.environmentId)) - .toSorted( - (left, right) => - Number(right.environmentId === primaryEnvironmentId) - - Number(left.environmentId === primaryEnvironmentId), - ); - const exactProject = findProjectForChangeRequest(projects, parsed); - const project = - exactProject ?? - (resolvedPanelRef - ? findProjectOnChangeRequestHost( - projects.filter( - (candidate) => - serverConfigs.get(candidate.environmentId)?.environment.capabilities - .threadPullRequests === true, - ), - parsed, - ) - : undefined); - if (project === undefined || !reads(project.environmentId)) return false; - const repository = - serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === true - ? parsed.repository - : (sourceControlClients.repositorySelector(project.repositoryIdentity) ?? - parsed.repository); + const target = resolveChangeRequestLinkTarget({ + url: targetUrl, + projects: readProjects(), + capabilities: (environmentId) => serverConfigs.get(environmentId)?.environment.capabilities, + environmentId: resolvedThreadRef?.environmentId ?? targetEnvironmentId, + primaryEnvironmentId, + lendHostProject: resolvedPanelRef !== undefined, + }); + if (target === null) return false; + const { project, repository, number, host } = target; event.preventDefault(); event.stopPropagation(); + const pullRequestsSearch = { + repository, + number, + ...(host === undefined ? {} : { selectedHost: host }), + selectedProjectId: project.id, + selectedEnvironmentId: project.environmentId, + }; if (resolvedPanelRef) { useRightPanelStore.getState().openPullRequest(resolvedPanelRef, { // The standalone PR panel has a synthetic ref; each tab keeps its real environment. @@ -125,13 +85,10 @@ export function useOpenChangeRequestLink( ? {} : { environmentId: project.environmentId }), projectId: project.id, - ...(serverConfigs.get(project.environmentId)?.environment.capabilities - .threadPullRequests === true - ? { host: parsed.authority ?? parsed.host } - : {}), + ...(host === undefined ? {} : { host }), repository, url: targetUrl, - number: parsed.number, + number, }); if (!resolvedThreadRef) { void navigate({ @@ -140,11 +97,7 @@ export function useOpenChangeRequestLink( ...previous, involvement: previous.involvement ?? "all", state: previous.state ?? "all", - repository, - number: parsed.number, - selectedHost: parsed.authority ?? parsed.host, - selectedProjectId: project.id, - selectedEnvironmentId: project.environmentId, + ...pullRequestsSearch, }), replace: true, }); @@ -153,15 +106,7 @@ export function useOpenChangeRequestLink( } void navigate({ to: "/pull-requests", - search: { - involvement: "all", - state: "all", - repository, - number: parsed.number, - selectedHost: parsed.authority ?? parsed.host, - selectedProjectId: project.id, - selectedEnvironmentId: project.environmentId, - }, + search: { involvement: "all", state: "all", ...pullRequestsSearch }, }); return true; }; diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts index be25a3e5b10b..243c71913b13 100644 --- a/packages/client-runtime/src/changeRequestLinks.test.ts +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -8,7 +8,9 @@ import { describe, expect, it } from "vite-plus/test"; import { findProjectForChangeRequest, + findProjectOnChangeRequestHost, projectChangeRequestUrl, + resolveChangeRequestLinkTarget, resolvePullRequestPreviewTarget, } from "./changeRequestLinks.ts"; @@ -54,6 +56,112 @@ describe("findProjectForChangeRequest", () => { }); }); +describe("findProjectOnChangeRequestHost", () => { + const project = (id: string, identity: Record) => + ({ id, repositoryIdentity: identity }) as never; + const frontend = project("frontend", { + canonicalKey: "github.com/acme/frontend", + provider: "github", + owner: "acme", + name: "frontend", + }); + const backend = project("backend", { + canonicalKey: "github.com/acme/backend", + provider: "github", + owner: "acme", + name: "backend", + }); + + it("prefers the project checked out from the link's own repository", () => { + expect( + findProjectOnChangeRequestHost([frontend, backend], { + host: "github.com", + repository: "acme/backend", + number: 7, + }), + ).toBe(backend); + }); + + it("lends any project on the host to a repository nobody has checked out", () => { + expect( + findProjectOnChangeRequestHost([frontend], { + host: "github.com", + repository: "acme/backend", + number: 7, + }), + ).toBe(frontend); + }); +}); + +describe("resolveChangeRequestLinkTarget", () => { + const local = EnvironmentId.make("local"); + const remote = EnvironmentId.make("remote"); + const project = (id: string, environmentId: EnvironmentId, canonicalKey: string) => ({ + id: ProjectId.make(id), + environmentId, + repositoryIdentity: { + canonicalKey, + provider: SourceControlProviderKind.make( + canonicalKey.startsWith("git.cafe") ? "gitcafe" : "github", + ), + displayName: canonicalKey.split("/").slice(1).join("/"), + locator: { source: "git-remote", remoteName: "origin", remoteUrl: `https://${canonicalKey}` }, + } satisfies RepositoryIdentity, + }); + const mirrored = project("mirrored", local, "git.cafe/acme/app"); + const sibling = project("sibling", local, "github.com/acme/tools"); + const elsewhere = project("elsewhere", remote, "github.com/acme/app"); + const projects = [mirrored, sibling, elsewhere]; + const hostReads = () => ({ pullRequests: true, threadPullRequests: true }); + const url = "https://github.com/acme/app/pull/7"; + + it("opens a mirror's link in its own environment under a project on the link's host", () => { + expect( + resolveChangeRequestLinkTarget({ + url, + projects, + capabilities: hostReads, + environmentId: local, + projectId: mirrored.id, + lendHostProject: true, + }), + ).toEqual({ project: sibling, host: "github.com", repository: "acme/app", number: 7 }); + }); + + it("only borrows a host project for views that can show any repository on the host", () => { + const resolve = (capabilities: typeof hostReads, lendHostProject: boolean) => + resolveChangeRequestLinkTarget({ + url, + projects, + capabilities, + environmentId: local, + lendHostProject, + }); + expect(resolve(hostReads, false)).toBeNull(); + expect(resolve(() => ({ pullRequests: true, threadPullRequests: false }), true)).toBeNull(); + }); + + it("finds the checked-out repository in any environment that reads change requests", () => { + expect( + resolveChangeRequestLinkTarget({ + url, + projects, + capabilities: hostReads, + primaryEnvironmentId: local, + lendHostProject: false, + })?.project, + ).toBe(elsewhere); + expect( + resolveChangeRequestLinkTarget({ + url, + projects, + capabilities: (environmentId) => (environmentId === remote ? undefined : hostReads()), + lendHostProject: false, + }), + ).toBeNull(); + }); +}); + describe("resolvePullRequestPreviewTarget", () => { const environmentId = EnvironmentId.make("env-1"); const project = { diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts index 2629a934c58b..cdf0bf7732f1 100644 --- a/packages/client-runtime/src/changeRequestLinks.ts +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -28,6 +28,98 @@ export function findProjectForChangeRequest( + projects: ReadonlyArray, + link: ChangeRequestLink, +): Project | undefined { + return ( + findProjectForChangeRequest(projects, link) ?? + projects.find((project) => { + const identity = project.repositoryIdentity; + return ( + identity?.provider !== undefined && + sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link) + ); + }) + ); +} + +export interface ChangeRequestLinkCapabilities { + readonly pullRequests?: boolean | undefined; + readonly threadPullRequests?: boolean | undefined; +} + +/** + * Where every client opens a change request link in the app, or null for the browser. A link + * opened into a pull request view may borrow any project on its host when the server reads + * change requests by host, so a mirror's or a sibling repository's link still opens in the app. + */ +export function resolveChangeRequestLinkTarget({ + url, + projects, + capabilities, + environmentId, + projectId, + primaryEnvironmentId, + lendHostProject, +}: { + url: string; + projects: ReadonlyArray; + capabilities: (environmentId: EnvironmentId) => ChangeRequestLinkCapabilities | undefined; + /** The link's own environment; without one, any environment that reads change requests. */ + environmentId?: EnvironmentId | undefined; + /** The project the link came from, tried first. */ + projectId?: ProjectId | undefined; + primaryEnvironmentId?: EnvironmentId | null | undefined; + lendHostProject: boolean; +}): { + readonly project: Project; + readonly repository: string; + readonly number: number; + readonly host?: string; +} | null { + const link = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (link === undefined) return null; + // Matching remotes do not make project ids usable outside the link's environment. + const candidates = ( + environmentId === undefined + ? projects.filter((project) => capabilities(project.environmentId)?.pullRequests === true) + : projects.filter((project) => project.environmentId === environmentId) + ).sort( + (left, right) => + Number(right.id === projectId) - Number(left.id === projectId) || + Number(right.environmentId === primaryEnvironmentId) - + Number(left.environmentId === primaryEnvironmentId), + ); + const project = + findProjectForChangeRequest(candidates, link) ?? + (lendHostProject + ? findProjectOnChangeRequestHost( + candidates.filter( + (candidate) => capabilities(candidate.environmentId)?.threadPullRequests === true, + ), + link, + ) + : undefined); + if (project === undefined) return null; + const projectCapabilities = capabilities(project.environmentId); + if (projectCapabilities?.pullRequests !== true) return null; + return projectCapabilities.threadPullRequests === true + ? { + project, + repository: link.repository, + number: link.number, + host: link.authority ?? link.host, + } + : { + project, + repository: + sourceControlClients.repositorySelector(project.repositoryIdentity) ?? link.repository, + number: link.number, + }; +} + export function resolvePullRequestPreviewTarget({ environmentId, projects, From e9d61252f6fd8369b80fe8f7e07209cbbb7221d9 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 25 Oct 2026 05:00:04 +0000 Subject: [PATCH 08/34] feat(web): command palette search finds options inside submenus (#24709) --- .../components/CommandPalette.logic.test.ts | 51 ++++++++++++++++ .../src/components/CommandPalette.logic.ts | 58 +++++++++++++++++-- apps/web/src/components/CommandPalette.tsx | 9 +++ .../src/components/CommandPaletteResults.tsx | 14 +---- apps/web/src/components/ThreadSearchMatch.tsx | 2 +- 5 files changed, 118 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index dfa8b1951c10..f37f5405c590 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import type { Thread } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { + buildNestedSearchItems, buildThreadActionItems, buildLinkedThreadActionItems, filterPinnedBrowseEntries, @@ -168,6 +169,56 @@ describe("buildThreadActionItems", () => { ]); }); + it("searches submenu options from the root and from a submenu with no match", () => { + const action = (value: string, title: string) => ({ + kind: "action" as const, + value, + searchTerms: [title], + title, + icon: null, + run: async () => undefined, + }); + const projects = [action("new-thread-in:t3code", "t3code"), action("new-thread-in:web", "web")]; + const newThreadIn = { + kind: "submenu" as const, + value: "action:new-thread-in", + searchTerms: ["new thread"], + title: "New thread in...", + icon: null, + addonIcon: null, + groups: [{ value: "projects", label: "Projects", items: projects }], + }; + const addProject = action("action:add-project", "Add project"); + const rootGroups = [{ value: "actions", label: "Actions", items: [newThreadIn, addProject] }]; + const search = (query: string, isInSubmenu: boolean) => + filterCommandPaletteGroups({ + activeGroups: isInSubmenu ? newThreadIn.groups : rootGroups, + query, + isInSubmenu, + allOptions: { + rootGroups, + nestedItems: buildNestedSearchItems([newThreadIn, addProject], query), + }, + projectSearchItems: [], + threadSearchItems: [ + ...buildThreadActionItems({ + threads: [makeThread({ title: "Thread about t3code" })], + projectByKey: new Map(), + icon: null, + runThread: async () => undefined, + }), + ], + }).map((group) => [group.label, group.items.map((item) => item.title)]); + + expect(search("new thread in t3code", false)[0]).toEqual([ + "Suggestions", + ["New thread in t3code"], + ]); + expect(search("new thread", false).map(([label]) => label)).not.toContain("Suggestions"); + expect(search("t3", true)).toEqual([["Projects", ["t3code"]]]); + expect(search("add project", true)).toEqual([["Actions", ["Add project"]]]); + }); + it("lists message matches last, once, without moving the title matches above them", () => { const threads = [ makeThread({ id: ThreadId.make("title"), title: "Disable Convex schema validation" }), diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 16db18f86fe7..7ac1410b2c87 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -168,8 +168,6 @@ export interface CommandPaletteItem { readonly timestamp?: string; /** Thread rows rank by this, with use history, instead of by `searchTerms`. */ readonly threadSearch?: ThreadSearchSubject; - /** Title ranges the current query matched, for highlighting. */ - readonly titleRanges?: ReadonlyArray; /** Shown in place of the description when the match is not in the title. */ readonly matchHint?: SearchMatchHint; readonly icon: ReactNode; @@ -508,6 +506,11 @@ export function filterCommandPaletteGroups(input: { activeGroups: ReadonlyArray; query: string; isInSubmenu: boolean; + /** Submenu options join the root search, and a submenu with no match falls back to it. */ + allOptions?: { + readonly rootGroups: ReadonlyArray; + readonly nestedItems: ReadonlyArray; + }; projectSearchItems: ReadonlyArray; settingsSearchItems?: ReadonlyArray; threadSearchItems: ReadonlyArray; @@ -515,6 +518,17 @@ export function filterCommandPaletteGroups(input: { threadMessageItems?: ReadonlyArray; now?: number; }): CommandPaletteGroup[] { + if (input.allOptions && input.isInSubmenu) { + const { allOptions, ...submenuInput } = input; + const scoped = filterCommandPaletteGroups(submenuInput); + if (scoped.length > 0 || input.query.trim().length === 0) return scoped; + return filterCommandPaletteGroups({ + ...input, + activeGroups: allOptions.rootGroups, + isInSubmenu: false, + }); + } + const isActionsFilter = input.query.startsWith(">"); const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; const normalizedQuery = foldSearchQuery(searchQuery); @@ -538,6 +552,13 @@ export function filterCommandPaletteGroups(input: { // On equal match classes, groups keep this order: threads, what people search // for most, right after commands. if (searchesEverything) { + if (input.allOptions && input.allOptions.nestedItems.length > 0) { + searchableGroups.push({ + value: "nested-search", + label: "Suggestions", + items: input.allOptions.nestedItems, + }); + } if (input.threadSearchItems.length > 0) { searchableGroups.push({ value: "threads-search", @@ -572,7 +593,7 @@ export function filterCommandPaletteGroups(input: { const limit = group.value === "threads-search" ? PALETTE_THREAD_LIMIT - : searchesEverything && threadsMatch + : group.value === "nested-search" || (searchesEverything && threadsMatch) ? PALETTE_SECONDARY_LIMIT : ranked.length; // Hints are built only for the rows shown. @@ -583,7 +604,7 @@ export function filterCommandPaletteGroups(input: { ) .slice(0, limit) .map(({ item, match }) => { - if (match.ranges.length > 0) return { ...item, titleRanges: match.ranges }; + if (match.ranges.length > 0) return item; // Built only for the rows shown. const matchHint = match.explain?.(); return matchHint ? { ...item, matchHint } : item; @@ -600,6 +621,35 @@ export function filterCommandPaletteGroups(input: { : [...groups, { value: "threads-messages", label: "In messages", items: messageItems }]; } +/** Every option inside a submenu as one row, e.g. "New thread in t3code" or "Change theme: Dracula". */ +export function buildNestedSearchItems( + items: ReadonlyArray, + query: string, +): CommandPaletteActionItem[] { + const nameOf = (item: CommandPaletteItem) => + typeof item.title === "string" ? item.title : item.searchTitle; + const topLevelNames = new Set(items.map(nameOf)); + const foldedQuery = foldSearchQuery(query); + return items.flatMap((parent) => { + if (parent.kind !== "submenu" || typeof parent.title !== "string") return []; + const stem = parent.title.replace(/(\.\.\.|…)$/u, ""); + // A query that only names the submenu ("new thread in") wants the submenu row itself. + if (foldSearchQuery(stem).startsWith(foldedQuery)) return []; + const prefix = stem === parent.title ? `${stem}: ` : `${stem.trimEnd()} `; + return parent.groups.flatMap((group) => + group.items.flatMap((child) => { + const name = nameOf(child); + if (child.kind !== "action" || name === undefined) return []; + const title = `${prefix}${name}`; + if (topLevelNames.has(title)) return []; + const { searchTitle: _searchTitle, ...rest } = child; + // Only the combined title: child keywords like "theme" would list every theme. + return [{ ...rest, title, searchTerms: [title] }]; + }), + ); + }); +} + export function buildBrowseGroups(input: { browseEntries: ReadonlyArray; browseQuery: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 456a2289c816..7951655a2daf 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -167,6 +167,7 @@ import { type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, + buildNestedSearchItems, filterPinnedBrowseEntries, getCommandPaletteInputPlaceholder, getCommandPaletteMode, @@ -1681,6 +1682,14 @@ function OpenCommandPaletteDialog(props: { activeGroups, query: deferredQuery, isInSubmenu: currentView !== null, + ...(deferredQuery.trim().length > 0 + ? { + allOptions: { + rootGroups, + nestedItems: buildNestedSearchItems(actionItems, deferredQuery), + }, + } + : {}), projectSearchItems: projectSearchItems, settingsSearchItems, threadSearchItems: diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index e951be2135e7..c481d57d5fc8 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -22,11 +22,7 @@ import { CommandShortcut, } from "./ui/command"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; -import { - HighlightedSearchText, - SearchMatchHintLine, - ThreadSearchMatchExcerpt, -} from "./ThreadSearchMatch"; +import { SearchMatchHintLine, ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; interface CommandPaletteResultsProps { emptyStateMessage?: string; @@ -159,16 +155,12 @@ export function CommandPaletteTitleIcon(props: { children: ReactNode }) { function CommandPaletteTitle({ item, }: { - item: Pick; + item: Pick; }) { const { title } = item; return ( - {typeof title === "string" && item.titleRanges ? ( - - ) : ( - title - )} + {title} ); } diff --git a/apps/web/src/components/ThreadSearchMatch.tsx b/apps/web/src/components/ThreadSearchMatch.tsx index 1af7f2810903..5f99304f6b82 100644 --- a/apps/web/src/components/ThreadSearchMatch.tsx +++ b/apps/web/src/components/ThreadSearchMatch.tsx @@ -6,7 +6,7 @@ import { } from "@t3tools/shared/paletteSearch"; /** `text` with each range in semibold foreground. */ -export function HighlightedSearchText(props: { text: string; ranges: ReadonlyArray }) { +function HighlightedSearchText(props: { text: string; ranges: ReadonlyArray }) { if (props.ranges.length === 0) return props.text; return splitSearchText(props.text, props.ranges).map((part) => part.highlighted ? ( From 8c0670b2be8ea315ff790415db2cf76bcae8e0a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 22:24:07 -0700 Subject: [PATCH 09/34] fix(mobile): one way to select a page, so split view stops pushing (#24742) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 18 +- apps/mobile/src/Stack.tsx | 12 +- .../features/files/ThreadFilesRouteScreen.tsx | 6 +- .../src/features/home/HomeRouteScreen.tsx | 20 +- .../home/home-thread-navigation.test.ts | 238 ------------------ .../features/home/home-thread-navigation.ts | 114 --------- .../home/usePendingTaskListActions.ts | 6 +- .../src/features/keyboard/CommandPalette.tsx | 43 ++-- .../HardwareKeyboardCommandProvider.tsx | 3 +- .../layout/AdaptiveWorkspaceLayout.tsx | 109 +++----- .../layout/adaptive-workspace-context.ts | 11 +- .../createWorkspaceStackNavigator.ios.tsx | 56 +++-- .../pull-requests/PullRequestStackCard.tsx | 6 +- .../pull-requests/PullRequestsScreen.tsx | 9 +- .../usePullRequestListActions.ts | 13 +- .../pull-requests/usePullRequestThreads.ts | 25 +- .../src/features/review/ReviewSheet.tsx | 8 +- .../showcase/ShowcaseCaptureCoordinator.tsx | 3 +- .../features/threads/NewTaskRouteScreen.tsx | 35 +-- .../src/features/threads/ThreadFeed.tsx | 8 +- .../threads/ThreadNavigationSidebar.tsx | 6 +- .../features/threads/ThreadRouteScreen.tsx | 8 +- .../workspace/useAndroidWorkspaceSelection.ts | 27 -- .../workspace/useSelectWorkspaceDetail.ts | 21 ++ .../workspace-detail-selection.test.ts | 141 +++++++++++ .../workspace/workspace-detail-selection.ts | 114 +++++++++ .../workspace/workspace-selection-store.ts | 20 +- .../src/lib/adaptive-navigation.test.ts | 113 --------- apps/mobile/src/lib/adaptive-navigation.ts | 56 ----- .../lib/android-workspace-navigation.test.ts | 111 ++------ .../src/lib/android-workspace-navigation.ts | 43 +--- .../src/native/createV5StackNavigator.ios.tsx | 24 +- apps/mobile/src/native/useOpenPullRequest.ts | 11 +- docs/internals/mobile-navigation.md | 26 ++ oxlint-plugin-t3code/index.ts | 2 + .../no-ambiguous-detail-navigation.test.ts | 25 ++ .../rules/no-ambiguous-detail-navigation.ts | 35 +++ vite.config.ts | 5 + 38 files changed, 654 insertions(+), 877 deletions(-) delete mode 100644 apps/mobile/src/features/home/home-thread-navigation.test.ts delete mode 100644 apps/mobile/src/features/home/home-thread-navigation.ts delete mode 100644 apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts create mode 100644 apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts create mode 100644 apps/mobile/src/features/workspace/workspace-detail-selection.test.ts create mode 100644 apps/mobile/src/features/workspace/workspace-detail-selection.ts delete mode 100644 apps/mobile/src/lib/adaptive-navigation.test.ts delete mode 100644 apps/mobile/src/lib/adaptive-navigation.ts create mode 100644 oxlint-plugin-t3code/rules/no-ambiguous-detail-navigation.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-ambiguous-detail-navigation.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 1d5b2cdd570e..8b444aa08f4f 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -6,7 +6,12 @@ import { StatusBar } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { createStaticNavigation, getStateFromPath } from "@react-navigation/native"; +import { + createNavigationContainerRef, + createStaticNavigation, + getActionFromState, + getStateFromPath, +} from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -38,6 +43,7 @@ import { recordWorkspaceSelections, settingsLinkPage, } from "./features/workspace/workspace-selection-store"; +import { workspaceDetailLinkAction } from "./features/workspace/workspace-detail-selection"; import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; import "../global.css"; @@ -54,6 +60,13 @@ const appLinking = { config: { initialRouteName: "Home" }, filter: shouldHandleAppLink, getInitialURL: async () => (await Linking.getInitialURL()) ?? (await launchWorkspaceURL()), + getActionFromState: ( + state: Parameters[0], + options: Parameters[1], + ): ReturnType => + // Our router handles the selection; React Navigation's type only lists its own actions. + (workspaceDetailLinkAction(state) as ReturnType) ?? + getActionFromState(state, options), getStateFromPath: (path: string, options: Parameters[1]) => restoreSettingsLinkState( restoreDraftLinkState(getStateFromPath(path, options), path), @@ -62,6 +75,7 @@ const appLinking = { }; const Navigation = createStaticNavigation(RootStack); +const navigationRef = createNavigationContainerRef(); function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); @@ -116,7 +130,9 @@ function AppContent() { recordWorkspaceSelections(navigationRef.getRootState())} theme={navigationTheme} onStateChange={recordWorkspaceSelections} /> diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index fad15278907a..8a07f8b7a086 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -149,6 +149,10 @@ import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; +import { + selectWorkspaceDetail, + workspaceDetailRouter, +} from "./features/workspace/workspace-detail-selection"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -525,7 +529,12 @@ function RootStackLayout(props: { if (!transition.shareIdToPresent) { return; } - navigation.navigate("Thread", { draft: { incomingShareId: transition.shareIdToPresent } }); + navigation.dispatch( + selectWorkspaceDetail({ + name: "Thread", + params: { draft: { incomingShareId: transition.shareIdToPresent } }, + }), + ); }, [navigation, pendingShare, props.state]); return ( @@ -599,6 +608,7 @@ function NotFoundScreen() { const RootStackConfig = createWorkspaceStackNavigator({ initialRouteName: "Home", + UNSTABLE_router: workspaceDetailRouter, // React Navigation calls layouts inline; compiled components would share the navigator's memo cache. layout: (props) => , screenOptions: { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 9ef668a9fa1c..7df96335579f 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -22,7 +22,6 @@ import { AudioFilePreview } from "./AudioFilePreview"; import { EmptyState } from "../../components/ui/EmptyState"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { LoadingScreen } from "../../components/LoadingScreen"; -import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { isPdfFile } from "../../lib/filePreview"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; @@ -478,10 +477,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { navigation.navigate("NewTaskFile", params); return; } - const navigationAction = resolveFileSelectionNavigationAction({ - hasPersistentFileInspector: hasFileInspector, - }); - if (navigationAction === "replace") { + if (hasFileInspector) { navigation.dispatch(StackActions.replace("ThreadFile", params)); return; } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index d6f5262b255a..5756a140e779 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -28,7 +28,6 @@ import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; -import { useHomeThreadRouteSelection, useHomeThreadSelection } from "./home-thread-navigation"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; @@ -36,6 +35,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; export function HomeRouteScreen() { const { width: windowWidth } = useWindowDimensions(); @@ -45,6 +45,7 @@ export function HomeRouteScreen() { const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery, startNewTask } = useAdaptiveWorkspaceLayout(); + const selectDetail = useSelectWorkspaceDetail(); const projects = useProjects(); // Stop rebuilding the streaming thread list while Thread covers Home. const visible = useHomeRouteVisible(); @@ -67,8 +68,18 @@ export function HomeRouteScreen() { else if (workspace) workspace.dispatch({ type: "query", query, tab: "threads" }); else setLocalSearchQuery(query); }; - const handleSelectThread = useHomeThreadSelection(); - const selectThreadRoute = useHomeThreadRouteSelection(); + const handleSelectThread = useCallback( + (thread: EnvironmentThreadShell) => + selectDetail({ + name: "Thread", + params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, + }), + [selectDetail], + ); + const selectThreadRoute = useCallback( + (params: ReactNavigation.RootParamList["Thread"]) => selectDetail({ name: "Thread", params }), + [selectDetail], + ); const handleNewThreadOnBranch = useCallback( (thread: EnvironmentThreadShell) => { selectThreadRoute({ @@ -104,7 +115,8 @@ export function HomeRouteScreen() { unsettleThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); - const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); + const { openPendingTask, confirmDeletePendingTask } = + usePendingTaskListActions(selectThreadRoute); const environments = useMemo(() => { const connectionStateByEnvironmentId = new Map( workspaceEnvironments.map( diff --git a/apps/mobile/src/features/home/home-thread-navigation.test.ts b/apps/mobile/src/features/home/home-thread-navigation.test.ts deleted file mode 100644 index d02d602767af..000000000000 --- a/apps/mobile/src/features/home/home-thread-navigation.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import * as NodeModule from "node:module"; -import type { - StackNavigationState, - StackRouter as StackRouterType, - StackActions as StackActionsType, -} from "@react-navigation/native"; -import { describe, expect, it, vi } from "vite-plus/test"; - -function loadRouters() { - const require = NodeModule.createRequire(import.meta.url); - const nativePackage = require.resolve("@react-navigation/native/package.json"); - const requireFromNative = NodeModule.createRequire(nativePackage); - const corePackage = requireFromNative.resolve("@react-navigation/core/package.json"); - const requireFromCore = NodeModule.createRequire(corePackage); - return requireFromCore("@react-navigation/routers") as { - readonly CommonActions: typeof import("@react-navigation/native").CommonActions; - readonly StackActions: typeof StackActionsType; - readonly StackRouter: typeof StackRouterType; - }; -} - -vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } })); - -vi.mock("../workspace/useAndroidWorkspaceSelection", () => ({ - useAndroidWorkspaceSelection: vi.fn(), -})); - -vi.mock("@react-navigation/native", () => { - const { CommonActions, StackActions } = loadRouters(); - return { CommonActions, StackActions }; -}); - -import { - createHomeThreadRouteAction, - createHomeThreadNavigationAction, -} from "./home-thread-navigation"; - -const { StackActions, StackRouter } = loadRouters(); -const routeNames = ["Home", "Thread"]; -const routeParamList = { - Home: undefined, - Thread: undefined, -}; -const router = StackRouter({}); -const routerOptions = { - routeNames, - routeParamList, - routeGetIdList: {}, -}; - -type ThreadSelection = Parameters[0]["thread"]; - -function thread(id: string): ThreadSelection { - return { - environmentId: "environment-1", - id, - } as ThreadSelection; -} - -function initialState() { - return router.getInitialState(routerOptions); -} - -function apply( - state: StackNavigationState>, - action: Parameters[1], -) { - const nextState = router.getStateForAction(state, action, routerOptions); - expect(nextState).not.toBeNull(); - return nextState as StackNavigationState>; -} - -function selectThread( - state: ReturnType, - selectedThread: ThreadSelection, - dismissingRouteKey: string | null = null, -) { - return apply( - state, - createHomeThreadNavigationAction({ - state, - dismissingRouteKey, - thread: selectedThread, - }), - ); -} - -function dismissRoute(state: ReturnType, routeKey: string) { - return apply(state, { - ...StackActions.pop(), - source: routeKey, - target: state.key, - }); -} - -describe("createHomeThreadNavigationAction", () => { - it("coalesces ordinary repeat selections onto the current thread route", () => { - const firstState = selectThread(initialState(), thread("thread-a")); - const threadRouteKey = firstState.routes[firstState.index]?.key; - const secondAction = createHomeThreadNavigationAction({ - state: firstState, - dismissingRouteKey: null, - thread: thread("thread-b"), - }); - - const secondState = apply(firstState, secondAction); - expect(secondState.routes).toHaveLength(2); - expect(secondState.routes[secondState.index]).toMatchObject({ - key: threadRouteKey, - name: "Thread", - params: { environmentId: "environment-1", threadId: "thread-b" }, - }); - }); - - it("keeps an overlap selection after native dismisses the outgoing route", () => { - const outgoingState = selectThread(initialState(), thread("thread-a")); - const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; - expect(outgoingRouteKey).toBeDefined(); - - const overlapAction = createHomeThreadNavigationAction({ - state: outgoingState, - dismissingRouteKey: outgoingRouteKey ?? null, - thread: thread("thread-b"), - }); - const overlapState = apply(outgoingState, overlapAction); - const incomingRoute = overlapState.routes[overlapState.index]; - expect(incomingRoute?.key).not.toBe(outgoingRouteKey); - - const dismissedState = dismissRoute(overlapState, outgoingRouteKey!); - expect(dismissedState.routes).toHaveLength(2); - expect(dismissedState.routes[dismissedState.index]).toMatchObject({ - key: incomingRoute?.key, - name: "Thread", - params: { environmentId: "environment-1", threadId: "thread-b" }, - }); - }); - - it("uses a fresh key for the same thread selected during dismissal", () => { - const outgoingState = selectThread(initialState(), thread("thread-a")); - const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; - expect(outgoingRouteKey).toBeDefined(); - - const overlapState = selectThread(outgoingState, thread("thread-a"), outgoingRouteKey ?? null); - const incomingRouteKey = overlapState.routes[overlapState.index]?.key; - expect(incomingRouteKey).not.toBe(outgoingRouteKey); - - const dismissedState = dismissRoute(overlapState, outgoingRouteKey!); - expect(dismissedState.routes[dismissedState.index]).toMatchObject({ - key: incomingRouteKey, - params: { environmentId: "environment-1", threadId: "thread-a" }, - }); - }); - - it("coalesces a second overlap selection onto the fresh incoming route", () => { - const outgoingState = selectThread(initialState(), thread("thread-a")); - const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; - expect(outgoingRouteKey).toBeDefined(); - - const firstOverlapState = selectThread( - outgoingState, - thread("thread-b"), - outgoingRouteKey ?? null, - ); - const incomingRouteKey = firstOverlapState.routes[firstOverlapState.index]?.key; - const secondOverlapAction = createHomeThreadNavigationAction({ - state: firstOverlapState, - dismissingRouteKey: outgoingRouteKey ?? null, - thread: thread("thread-c"), - }); - - const secondOverlapState = apply(firstOverlapState, secondOverlapAction); - expect(secondOverlapState.routes).toHaveLength(3); - expect(secondOverlapState.routes[secondOverlapState.index]).toMatchObject({ - key: incomingRouteKey, - params: { environmentId: "environment-1", threadId: "thread-c" }, - }); - - const dismissedState = dismissRoute(secondOverlapState, outgoingRouteKey!); - expect(dismissedState.routes).toHaveLength(2); - expect(dismissedState.routes[dismissedState.index]).toMatchObject({ - key: incomingRouteKey, - params: { environmentId: "environment-1", threadId: "thread-c" }, - }); - }); - - it("uses ordinary navigation when native pops before the selection", () => { - const outgoingState = selectThread(initialState(), thread("thread-a")); - const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; - expect(outgoingRouteKey).toBeDefined(); - - const poppedState = dismissRoute(outgoingState, outgoingRouteKey!); - const action = createHomeThreadNavigationAction({ - state: poppedState, - dismissingRouteKey: outgoingRouteKey ?? null, - thread: thread("thread-b"), - }); - - const selectedState = apply(poppedState, action); - expect(selectedState.routes).toHaveLength(2); - expect(selectedState.routes[selectedState.index]).toMatchObject({ - name: "Thread", - params: { environmentId: "environment-1", threadId: "thread-b" }, - }); - }); -}); - -describe("draft and live thread selection", () => { - it("keeps the screen key and clears old identity when switching both ways", () => { - const live = selectThread(initialState(), thread("thread-a")); - const key = live.routes[live.index]!.key; - const draft = apply( - live, - createHomeThreadRouteAction({ - state: live, - dismissingRouteKey: null, - params: { draft: { draftId: "new-task:draft-a" } }, - }), - ); - expect(draft.routes[draft.index]).toMatchObject({ - key, - params: { - environmentId: undefined, - threadId: undefined, - draft: { draftId: "new-task:draft-a" }, - }, - }); - const restored = selectThread(draft, thread("thread-b")); - expect(restored.routes[restored.index]).toMatchObject({ - key, - params: { - environmentId: "environment-1", - threadId: "thread-b", - draft: undefined, - }, - }); - expect(restored.routes).toHaveLength(live.routes.length); - }); -}); diff --git a/apps/mobile/src/features/home/home-thread-navigation.ts b/apps/mobile/src/features/home/home-thread-navigation.ts deleted file mode 100644 index 3c1b18bf6101..000000000000 --- a/apps/mobile/src/features/home/home-thread-navigation.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { - CommonActions, - StackActions, - useNavigation, - type NavigationState, -} from "@react-navigation/native"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useCallback, useEffect, useRef } from "react"; -import { Platform } from "react-native"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; - -type ThreadSelection = Pick; - -export function createHomeThreadRouteAction(input: { - readonly state: Pick; - readonly dismissingRouteKey: string | null; - readonly params: ReactNavigation.RootParamList["Thread"]; -}) { - const currentRoute = input.state.routes[input.state.index]; - if (input.dismissingRouteKey !== null && currentRoute?.key === input.dismissingRouteKey) { - return StackActions.push("Thread", input.params); - } - if (currentRoute?.name === "Thread") { - return { - ...CommonActions.setParams({ - environmentId: undefined, - threadId: undefined, - draft: undefined, - ...input.params, - }), - source: currentRoute.key, - }; - } - return CommonActions.navigate("Thread", input.params); -} - -export function createHomeThreadNavigationAction(input: { - readonly state: Pick; - readonly dismissingRouteKey: string | null; - readonly thread: ThreadSelection; -}) { - return createHomeThreadRouteAction({ - ...input, - params: { - environmentId: input.thread.environmentId, - threadId: input.thread.id, - }, - }); -} - -export function useHomeThreadRouteSelection() { - const navigation = - useNavigation>(); - const dismissingRouteKey = useRef(null); - const { layout, setPrimarySidebarVisible } = useAdaptiveWorkspaceLayout(); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); - - useEffect(() => { - const clear = () => { - dismissingRouteKey.current = null; - }; - // For Home, swipe-back is opening; Thread's closing event targets the outgoing route. - const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { - const state = navigation.getState(); - const currentRoute = state.routes[state.index]; - dismissingRouteKey.current = - !data.closing && currentRoute?.name === "Thread" ? currentRoute.key : null; - }); - const removeFocus = navigation.addListener("focus", clear); - - return () => { - clear(); - removeTransitionStart(); - removeFocus(); - }; - }, [navigation]); - - return useCallback( - (params: ReactNavigation.RootParamList["Thread"]) => { - if (Platform.OS === "android") { - selectAndroidWorkspace({ - name: "Thread", - params, - }); - return; - } - if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); - navigation.dispatch((state) => - createHomeThreadRouteAction({ - state, - dismissingRouteKey: dismissingRouteKey.current, - params, - }), - ); - }, - [ - layout.primarySidebarOverlaysContent, - navigation, - selectAndroidWorkspace, - setPrimarySidebarVisible, - ], - ); -} - -export function useHomeThreadSelection() { - const selectRoute = useHomeThreadRouteSelection(); - return useCallback( - (thread: ThreadSelection) => - selectRoute({ environmentId: thread.environmentId, threadId: thread.id }), - [selectRoute], - ); -} diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index f9c9d47f5264..698df0bfce70 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -1,5 +1,4 @@ import * as Dialog from "../../components/ui/Dialog"; -import { useHomeThreadRouteSelection } from "./home-thread-navigation"; import { useCallback } from "react"; import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; @@ -14,14 +13,11 @@ import { import { withThreadDismissal } from "./thread-dismissal"; export function usePendingTaskListActions( - selectRoute?: (params: ReactNavigation.RootParamList["Thread"]) => void, + selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void, ): { readonly openPendingTask: (pendingTask: PendingNewTask) => void; readonly confirmDeletePendingTask: (pendingTask: PendingNewTask) => void; } { - const selectHomeRoute = useHomeThreadRouteSelection(); - const selectThreadRoute = selectRoute ?? selectHomeRoute; - const openPendingTask = useCallback( (pendingTask: PendingNewTask) => { selectThreadRoute( diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 425fab9c56c5..c5c3608f6c75 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -48,7 +48,6 @@ import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; import { @@ -62,6 +61,7 @@ import { threadJumpIndex } from "./threadKeyboardShortcuts"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; const PALETTE_COMMANDS: ReadonlyArray = [ "commandPalette", @@ -212,7 +212,7 @@ export function CommandPalette(props: { const navigation = useNavigation(); const selectTab = useSelectWorkspaceTab(); const { themeVariables } = useAppearancePreferences(); - const { selectThread, selectThreadRoute } = useAdaptiveWorkspaceLayout(); + const selectDetail = useSelectWorkspaceDetail(); const runCommand = props.onCommand; const projects = useProjects(); const threads = useThreadShells(); @@ -323,12 +323,15 @@ export function CommandPalette(props: { title: `New thread in ${activeProject.title}`, searchTerms: ["new task", "chat", "create"], run: () => - selectThreadRoute({ - draft: { - requestId: uuidv4(), - environmentId: activeProject.environmentId, - projectId: activeProject.id, - title: activeProject.title, + selectDetail({ + name: "Thread", + params: { + draft: { + requestId: uuidv4(), + environmentId: activeProject.environmentId, + projectId: activeProject.id, + title: activeProject.title, + }, }, }), }); @@ -358,12 +361,15 @@ export function CommandPalette(props: { detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`, searchTerms: [project.workspaceRoot, "new thread", "project"], run: () => - selectThreadRoute({ - draft: { - requestId: uuidv4(), - environmentId: project.environmentId, - projectId: project.id, - title: project.title, + selectDetail({ + name: "Thread", + params: { + draft: { + requestId: uuidv4(), + environmentId: project.environmentId, + projectId: project.id, + title: project.title, + }, }, }), })); @@ -391,7 +397,11 @@ export function CommandPalette(props: { ...(thread.environmentId === activeThread?.environmentId && thread.id === activeThread.id ? { secondary: true } : {}), - run: () => selectThread(thread), + run: () => + selectDetail({ + name: "Thread", + params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, + }), }; }); // Recent means used recently: written in or opened, whichever is later. @@ -410,8 +420,7 @@ export function CommandPalette(props: { projects, runCommand, savedConnectionsById, - selectThread, - selectThreadRoute, + selectDetail, threads, ]); const rankedMessageKeys = useMemo(() => { diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index df104a711884..eeb06c4e2e30 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -32,6 +32,7 @@ import { subscribeToHardwareKeyboardCommandRegistrations, type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; +import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; const EMPTY_COPY_FEEDBACK: GitActionProgress = { phase: "idle", @@ -153,7 +154,7 @@ export function HardwareKeyboardCommandProvider({ } if (command === "newTask") { - navigation.navigate("Thread", { draft: {} }); + navigation.dispatch(selectWorkspaceDetail({ name: "Thread", params: { draft: {} } })); return; } if (command === "back") { diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 70463d795353..8de05e3118da 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -9,13 +9,7 @@ import type { import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { - CommonActions, - NavigationContext, - NavigationRouteContext, - StackActions, - useNavigation, -} from "@react-navigation/native"; +import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; import { use, useDeferredValue, @@ -26,7 +20,7 @@ import { useState, type ReactNode, } from "react"; -import { Platform, useWindowDimensions, View } from "react-native"; +import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { runOnJS, @@ -47,10 +41,6 @@ import { isWorkspaceListPath, type WorkspaceAuxiliaryPaneRole, } from "../../lib/layout"; -import { - resolveThreadSelectionNavigationAction, - resolveThreadSelectionOverlayState, -} from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -71,7 +61,11 @@ import { ANDROID_WORKSPACE_RAIL_WIDTH, } from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { + selectWorkspaceDetail, + type WorkspaceDetail, + type WorkspaceDetailSheet, +} from "../workspace/workspace-detail-selection"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; @@ -493,78 +487,46 @@ function AdaptiveWorkspaceLayoutContent( }; const handleOpenSettings = () => openWorkspaceTab("settings"); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); const deferredThreadSelection = useMemo(() => createDeferredNavigation(), []); - function handleSelectThreadRoute(params: ReactNavigation.RootParamList["Thread"]) { - deferredThreadSelection.run(() => applyThreadRouteSelection(params)); - } - function applyThreadRouteSelection(params: ReactNavigation.RootParamList["Thread"]) { - if (Platform.OS === "android") { + /** The one way to show a page in the detail column; see `selectWorkspaceDetail`. */ + function handleSelectDetail(detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) { + deferredThreadSelection.run(() => { + if (Platform.OS === "android") Keyboard.dismiss(); setFileInspectorPreferredVisible(false); - selectAndroidWorkspace({ name: "Thread", params }); - return; - } - const navigationAction = resolveThreadSelectionNavigationAction({ - usesSplitView: layout.usesSplitView, - pathname, - workspaceRouteName: navigation - .getState() - ?.routes.find((route) => route.key === props.workspaceRouteKey)?.name, - }); - const overlayState = resolveThreadSelectionOverlayState({ - state: navigation.getState(), - workspaceRouteKey: props.workspaceRouteKey, - action: navigationAction, - params, + if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); + workspace?.dispatch({ + type: "select", + tab: detail.name === "Thread" ? "threads" : "pullRequests", + }); + navigation.dispatch(selectWorkspaceDetail(detail, sheet)); }); - if (overlayState !== null) { - setFileInspectorPreferredVisible(false); - navigation.dispatch(CommonActions.reset(overlayState)); - return; - } - if (navigationAction === "set-params") { - setFileInspectorPreferredVisible(false); - const state = navigation.getState(); - const route = state?.routes.find((candidate) => candidate.key === props.workspaceRouteKey); - if (route?.name === "Thread") { - navigation.dispatch({ - ...CommonActions.setParams({ - environmentId: undefined, - threadId: undefined, - draft: undefined, - ...params, - }), - source: route.key, - }); - } else { - navigation.navigate("Thread", params); - } - return; - } - if (navigationAction === "replace") { - setFileInspectorPreferredVisible(false); - navigation.dispatch(StackActions.replace("Thread", params)); - return; - } - navigation.navigate("Thread", params); } + const handleSelectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => + handleSelectDetail({ name: "Thread", params }); const projects = useProjects(); const compactAndroidSettings = Platform.OS === "android" && androidSettingsOpen && !layout.usesSplitView; + // Reads the open page when pressed, so the layout context doesn't change on every navigation. const handleStartNewTask = () => { - const thread = appAtomRegistry - .get(environmentThreadShells.navigationThreadShellsAtom) - .find((candidate) => candidate.environmentId === environmentId && candidate.id === threadId); - const currentRoute = navigation - .getState() - ?.routes.find((route) => route.key === props.workspaceRouteKey); - const draft = + const state = rootStackState(navigation); + const currentRoute = workspaceRoutesOf( + state?.routes.slice(0, state.index + 1) ?? [], + ).workspace.at(-1); + const params = currentRoute?.name === "Thread" - ? (currentRoute.params as ReactNavigation.RootParamList["Thread"])?.draft + ? (currentRoute.params as ReactNavigation.RootParamList["Thread"]) : undefined; const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; + const thread = appAtomRegistry + .get(environmentThreadShells.navigationThreadShellsAtom) + .find( + (candidate) => + candidate.environmentId === first(params?.environmentId) && + candidate.id === first(params?.threadId), + ); + const draft = params?.draft; const draftEnvironmentId = first(draft?.environmentId); const draftProjectId = first(draft?.projectId); const currentProject = thread @@ -786,8 +748,7 @@ function AdaptiveWorkspaceLayoutContent( primarySidebarSearchQuery, startNewTask: handleStartNewTask, deferThreadSelectionUntil: deferredThreadSelection.waitFor, - selectThread: handleSelectThread, - selectThreadRoute: handleSelectThreadRoute, + selectDetail: handleSelectDetail, activateAuxiliaryPaneRole, registerWorkspaceInspector, setPrimarySidebarSearchQuery, diff --git a/apps/mobile/src/features/layout/adaptive-workspace-context.ts b/apps/mobile/src/features/layout/adaptive-workspace-context.ts index 04d82f548f37..4f5f2033e029 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -1,6 +1,9 @@ import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; import { createContext, use, type ReactNode } from "react"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { + WorkspaceDetail, + WorkspaceDetailSheet, +} from "../workspace/workspace-detail-selection"; import { deriveFileInspectorPaneLayout, deriveLayout, @@ -18,8 +21,8 @@ interface AdaptiveWorkspaceContextValue { readonly primarySidebarSearchQuery: string; readonly startNewTask: () => void; readonly deferThreadSelectionUntil: (operation: Promise) => void; - readonly selectThread: (thread: EnvironmentThreadShell) => void; - readonly selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void; + /** Use `useSelectWorkspaceDetail`, which also works outside the layout. */ + readonly selectDetail?: (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => void; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; readonly setPrimarySidebarSearchQuery: (query: string) => void; @@ -60,8 +63,6 @@ export const AdaptiveWorkspaceContext = createContext undefined, deferThreadSelectionUntil: () => undefined, - selectThread: () => undefined, - selectThreadRoute: () => undefined, activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 136a1d50e141..61075bad190c 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -29,6 +29,7 @@ import { WorkspaceNativeTabs } from "../workspace/WorkspaceNativeTabs.ios"; import { workspaceUsesColumns } from "../workspace/workspace-tabs-state"; import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; +import { setNativelyPoppingWorkspaceRoute } from "../workspace/workspace-detail-selection"; import { useWorkspaceSidebarHeaderItem } from "./useWorkspaceSidebarHeaderItem"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; @@ -71,18 +72,12 @@ const isPullRequestRoute = (route: Route) => route.name === "PullRequestFile" || route.name === "PullRequestFiles"; -/** Only these screens follow the sidebar's state; a thread's header draws its own toggle. */ -function SidebarToggleHeader(props: ComponentProps) { - const sidebarItem = useWorkspaceSidebarHeaderItem(); - const options = { - ...props.options, - unstable_headerLeftItems: () => [sidebarItem], - } as AppNativeStackNavigationOptions as typeof props.options; - return ; -} +/** Beside the list, the first Settings page shows on one native screen that swaps its content. */ +const SETTINGS_DETAIL_SCREEN = "settings-detail"; function ColumnScreen(props: { readonly descriptor: Descriptor; + readonly screenKey?: string; readonly primary?: boolean; readonly attached?: boolean; readonly selectedThreadKey?: string | null; @@ -96,13 +91,20 @@ function ColumnScreen(props: { }) { const { descriptor } = props; // Options a screen set inside this workspace tab slot, over its route's. - const options = slotScreenOptions( + const slotOptions = slotScreenOptions( use(WorkspaceSlotOptionsContext), descriptor.route.key, descriptor.options, ); - const Header = - props.sidebarToggle && !options.unstable_headerLeftItems ? SidebarToggleHeader : V5StackHeader; + const sidebarItem = useWorkspaceSidebarHeaderItem(); + // Swapping header components would unmount the native header config, which hides the bar. + const options = + props.sidebarToggle && !slotOptions.unstable_headerLeftItems + ? ({ + ...slotOptions, + unstable_headerLeftItems: () => [sidebarItem], + } as AppNativeStackNavigationOptions as typeof slotOptions) + : slotOptions; const [presented, setPresented] = useState(false); const primaryColumn = useMemo( () => @@ -118,7 +120,7 @@ function ColumnScreen(props: { const key = descriptor.route.key; return ( { props.onDismiss(key, event.nativeEvent.isNativeDismiss); @@ -143,7 +145,7 @@ function ColumnScreen(props: { > -
+ layout.usesSplitView && detail[0] && isSettingsRoute(detail[0].name) ? detail[0] : undefined; // Split screens must remain mounted while UIKit animates a detached route away. const [screens, setScreens] = useState({ routes: props.detail, @@ -196,9 +201,13 @@ function WorkspaceColumns( screens.observedRoutes !== props.detail || screens.observedDescriptors !== props.descriptors ) { - const routes = reconcileStackScreens(screens.routes, props.detail, screens.completed); - const retained = new Set(routes.map((route) => route.key)); const active = new Set(props.detail.map((route) => route.key)); + const settingsSlot = settingsDetailIn(props.detail); + const routes = reconcileStackScreens(screens.routes, props.detail, screens.completed).filter( + // The slot's native screen stays; the pages it showed, and any above them, just go. + (route) => !settingsSlot || active.has(route.key) || !isSettingsRoute(route.name), + ); + const retained = new Set(routes.map((route) => route.key)); setScreens({ routes, descriptors: Object.fromEntries( @@ -280,6 +289,7 @@ function WorkspaceColumns( props.primary.key, ); collapsedColumn.current = transition.column; + setNativelyPoppingWorkspaceRoute(transition.column.leaving); if (transition.pop) props.navigation.dispatch(transition.pop); }; @@ -301,9 +311,11 @@ function WorkspaceColumns( onCollapse={() => { collapsed.current = true; collapsedColumn.current = { visible: compactColumn, leaving: null }; + setNativelyPoppingWorkspaceRoute(null); }} onExpand={() => { collapsed.current = false; + setNativelyPoppingWorkspaceRoute(null); // Native callbacks can outlive the render that created them, so decide from the live // router state. An expanded split has columns, so its tab gets a page beside the list. const live = props.navigation.getState(); @@ -392,15 +404,14 @@ function WorkspaceColumns( {screens.routes.map((route) => { const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key]; + const inSettingsSlot = settingsDetailIn(props.detail)?.key === route.key; + const screenKey = inSettingsSlot ? SETTINGS_DETAIL_SCREEN : route.key; return descriptor ? ( current.key === route.key)} onDismiss={handleNativeDismiss} onAppearance={followCollapsedSplit} @@ -469,6 +480,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean { const params = { ...props.params, number: layer.number }; if (Platform.OS === "android") - selectAndroidWorkspace({ name: "PullRequest", params }); + selectDetail({ name: "PullRequest", params }); else navigation.dispatch(StackActions.push("PullRequest", params)); } } diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index 6c48ceb446e9..1554c184d390 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -42,7 +42,7 @@ import { } from "./pullRequestList"; import { useDebouncedValue } from "../../state/queries"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; function PullRequestScrollView(props: ScrollViewProps) { return ( @@ -106,7 +106,7 @@ export function PullRequestsScreen( const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId) ? selectedEnvironmentId : null; - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); + const selectDetail = useSelectWorkspaceDetail(); const selectPullRequest = useCallback( (item: EnvironmentPullRequestListEntry) => { Keyboard.dismiss(); @@ -118,10 +118,9 @@ export function PullRequestsScreen( host: item.host, initialSummary: item, }; - if (Platform.OS === "android") selectAndroidWorkspace({ name: "PullRequest", params }); - else navigation.navigate("PullRequest", params); + selectDetail({ name: "PullRequest", params }); }, - [navigation, selectAndroidWorkspace], + [selectDetail], ); const allProjects = useProjects(); const savedControls = listControls.controlsFor(environmentId); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts b/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts index 188ecdd439a9..c7cfe62f43b5 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts @@ -22,7 +22,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList"; import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink"; import { pullRequestTarget } from "./usePullRequestDiff"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; export function usePullRequestListActions( environmentIds: readonly EnvironmentId[], @@ -33,7 +33,7 @@ export function usePullRequestListActions( ) { const registry = use(RegistryContext); const navigation = useNavigation(); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); + const selectDetail = useSelectWorkspaceDetail(); const permissionKey = JSON.stringify(environmentIds); const permissionAtom = (() => { const ids: readonly EnvironmentId[] = JSON.parse(permissionKey); @@ -80,7 +80,10 @@ export function usePullRequestListActions( ) throw new Error(`Review requests aren't available for this ${terminology.singular}.`); if (Platform.OS === "android") { - selectAndroidWorkspace({ name: "PullRequest", params: params(entry) }, params(entry)); + selectDetail( + { name: "PullRequest", params: params(entry) }, + { name: "PullRequestReviewers", params: params(entry) }, + ); } else navigation.navigate("PullRequestReviewers", params(entry)); return; } @@ -112,9 +115,7 @@ export function usePullRequestListActions( }; const select = (entry: EnvironmentPullRequestListEntry, action: string) => { if (action === "open") { - if (Platform.OS === "android") - selectAndroidWorkspace({ name: "PullRequest", params: params(entry) }); - else navigation.navigate("PullRequest", params(entry)); + selectDetail({ name: "PullRequest", params: params(entry) }); return; } if (action === "request-review") { diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index 16e04b593d4b..e1d9d8715106 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -1,7 +1,12 @@ import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Dialog from "../../components/ui/Dialog"; -import { useNavigation, useNavigationState, useRoute } from "@react-navigation/native"; +import { + StackActions, + useNavigation, + useNavigationState, + useRoute, +} from "@react-navigation/native"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { AuthOrchestrationOperateScope, @@ -15,7 +20,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useRef, useState } from "react"; import { Platform } from "react-native"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import { pullRequestComposerContext } from "../../lib/composerContext"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { uuidv4 } from "../../lib/uuid"; @@ -71,13 +76,16 @@ export function usePullRequestThreads( const canAsk = !connected || canOperate; const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); const navigation = useNavigation(); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); + const selectDetail = useSelectWorkspaceDetail(); const pending = useRef(false); const previousPrompts = useRef(new Map()); const openThread = (thread: LinkedThread) => { - const destination = { environmentId: params.environmentId, threadId: thread.id }; - if (Platform.OS === "android") selectAndroidWorkspace({ name: "Thread", params: destination }); - else navigation.navigate("Thread", destination); + openThreadRoute({ environmentId: params.environmentId, threadId: thread.id }); + }; + // Android shows one page at a time; iOS opens the thread over the pull request, so Back returns. + const openThreadRoute = (destination: ReactNavigation.RootParamList["Thread"]) => { + if (Platform.OS === "android") selectDetail({ name: "Thread", params: destination }); + else navigation.dispatch(StackActions.push("Thread", destination)); }; const [busy, setBusy] = useState(false); const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => { @@ -152,10 +160,7 @@ export function usePullRequestThreads( ); previousPrompts.current.set(draftKey, pullRequestAgentPrompt(task, terminology)); if (thread) openThread(thread); - else - navigation.navigate("Thread", { - draft: { draftId: draftKey, title: `Ask about #${pr.number}` }, - }); + else openThreadRoute({ draft: { draftId: draftKey, title: `Ask about #${pr.number}` } }); }); pending.current = false; setBusy(false); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 03cb70c8a492..6e864e2f3f5d 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -68,6 +68,7 @@ import { resolveReviewAvailability } from "./reviewAvailability"; import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; import { buildReviewSectionMenu } from "./review-section-menu"; import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; function ReviewHeader( props: Parameters[0] & { @@ -433,6 +434,7 @@ export function ReviewSheet(props: ReviewSheetProps) { useAdaptiveWorkspacePaneRole("inspector"); const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); + const selectDetail = useSelectWorkspaceDetail(); const insets = useSafeAreaInsets(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); const headerIcon = String(useUniwindTheme()["--color-icon"]); @@ -610,9 +612,9 @@ export function ReviewSheet(props: ReviewSheetProps) { navigation.goBack(); return; } - navigation.navigate("Thread", { - environmentId: String(environmentId), - threadId: String(threadId), + selectDetail({ + name: "Thread", + params: { environmentId: String(environmentId), threadId: String(threadId) }, }); }; const androidHeaderSubtitle = [ diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index bd6e79adc19b..10df7a298a4c 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -34,6 +34,7 @@ import { } from "./showcaseRenderSignal"; import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity"; import { openSettings } from "../settings/settings-navigation"; +import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; @@ -250,7 +251,7 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string }) } else if (requestedScene === "review") { navigation.dispatch(CommonActions.navigate("ThreadReview", params)); } else { - navigation.dispatch(CommonActions.navigate("Thread", params)); + navigation.dispatch(selectWorkspaceDetail({ name: "Thread", params })); } }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index f6e7354dfca5..01b3ed0c0f49 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -37,6 +37,7 @@ import { usePromptLinkResolver } from "../prompt-links/usePromptLinkResolver"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; @@ -154,6 +155,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 815794df3045..70aa6f22a619 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -289,6 +289,7 @@ import { ThreadMarkdownImageView, } from "./ThreadMarkdownImage"; import { openSettings } from "../settings/settings-navigation"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // iOS blockquotes and selectable text measure separately; definite bubble width matches UIKit's drawing width. @@ -435,6 +436,7 @@ function AssistantForkButton(props: { }); const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response"); const navigation = useNavigation(); + const selectDetail = useSelectWorkspaceDetail(); const [busy, setBusy] = useState(false); const canFork = canForkProjectedAssistantItem({ projectedItem: props.projectedItem, @@ -473,9 +475,9 @@ function AssistantForkButton(props: { ); return; } - navigation.navigate("Thread", { - environmentId: props.environmentId, - threadId: targetThreadId, + selectDetail({ + name: "Thread", + params: { environmentId: props.environmentId, threadId: targetThreadId }, }); }) .finally(() => setBusy(false)); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 2b6d7b306278..d56f635a04a3 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -78,6 +78,7 @@ import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "./threadListV2"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; type SidebarListItem = | ThreadListV2ListItem @@ -158,7 +159,10 @@ function ThreadNavigationSidebarPane( const pendingTasks = usePendingNewTasks(); const activeNewTask = useActiveNewTask(); const queuedThreadKeys = useQueuedThreadKeys(); - const { confirmDeletePendingTask } = usePendingTaskListActions(); + const selectDetail = useSelectWorkspaceDetail(); + const { confirmDeletePendingTask } = usePendingTaskListActions((params) => + selectDetail({ name: "Thread", params }), + ); const environments = Object.values(savedConnectionsById) .map((connection) => ({ environmentId: connection.environmentId, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 851fe0fb7ceb..45b11324c139 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -99,6 +99,7 @@ import { useWorkspaceBackNavigation, } from "../workspace/useAndroidWorkspaceNavigation"; import { openSettings } from "../settings/settings-navigation"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; @@ -340,6 +341,7 @@ function ThreadRouteContent( }; })(); const navigation = useNavigation(); + const selectDetail = useSelectWorkspaceDetail(); const openPullRequest = useOpenPullRequest(); const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back"); const mergeBackTargetThreadId = resolveMergeBackTargetThreadId(selectedThreadDetail); @@ -367,9 +369,9 @@ function ThreadRouteContent( }); mergeBackBusyRef.current = false; if (result._tag !== "Success") return; - navigation.navigate("Thread", { - environmentId: selectedThread.environmentId, - threadId: mergeBackTargetThreadId, + selectDetail({ + name: "Thread", + params: { environmentId: selectedThread.environmentId, threadId: mergeBackTargetThreadId }, }); }; const params = props.route.params ?? {}; diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts deleted file mode 100644 index fc536eedf3f0..000000000000 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useNavigation } from "@react-navigation/native"; -import { useCallback } from "react"; -import { Keyboard } from "react-native"; -import { - resetAndroidWorkspace, - type AndroidWorkspaceDestination, -} from "../../lib/android-workspace-navigation"; -import { useWorkspaceTabs } from "./WorkspaceTabsContext"; - -export function useAndroidWorkspaceSelection() { - const navigation = useNavigation(); - const dispatch = useWorkspaceTabs()?.dispatch; - return useCallback( - ( - destination: AndroidWorkspaceDestination, - reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], - ) => { - Keyboard.dismiss(); - dispatch?.({ - type: "select", - tab: destination.name === "Thread" ? "threads" : "pullRequests", - }); - resetAndroidWorkspace(navigation, destination, reviewers); - }, - [navigation, dispatch], - ); -} diff --git a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts b/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts new file mode 100644 index 000000000000..a149212ba438 --- /dev/null +++ b/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts @@ -0,0 +1,21 @@ +import { useNavigation } from "@react-navigation/native"; +import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { + selectWorkspaceDetail, + type WorkspaceDetail, + type WorkspaceDetailSheet, +} from "./workspace-detail-selection"; + +/** + * Shows a page in the detail column the way selecting its list row does. Inside the workspace + * layout this also settles its panes; outside it, the root stack's router still does the rest. + */ +export function useSelectWorkspaceDetail() { + const navigation = useNavigation(); + const { selectDetail } = useAdaptiveWorkspaceLayout(); + return ( + selectDetail ?? + ((detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => + navigation.dispatch(selectWorkspaceDetail(detail, sheet))) + ); +} diff --git a/apps/mobile/src/features/workspace/workspace-detail-selection.test.ts b/apps/mobile/src/features/workspace/workspace-detail-selection.test.ts new file mode 100644 index 000000000000..94e4e3be7dbc --- /dev/null +++ b/apps/mobile/src/features/workspace/workspace-detail-selection.test.ts @@ -0,0 +1,141 @@ +import * as NodeModule from "node:module"; +import { describe, expect, it } from "vite-plus/test"; +import { + selectWorkspaceDetail, + setNativelyPoppingWorkspaceRoute, + workspaceDetailLinkAction, + workspaceDetailRouter, + type WorkspaceDetail, +} from "./workspace-detail-selection"; + +const require = NodeModule.createRequire(import.meta.url); +const requireFromNative = NodeModule.createRequire( + require.resolve("@react-navigation/native/package.json"), +); +const requireFromCore = NodeModule.createRequire( + requireFromNative.resolve("@react-navigation/core/package.json"), +); +const { StackActions, StackRouter } = requireFromCore("@react-navigation/routers") as { + readonly StackActions: typeof import("@react-navigation/native").StackActions; + readonly StackRouter: typeof import("@react-navigation/native").StackRouter; +}; + +const base = StackRouter({}); +const router = { ...base, ...workspaceDetailRouter(base) }; +const options = { + routeNames: [ + "Home", + "Thread", + "ThreadFiles", + "PullRequest", + "PullRequestReviewers", + "Settings", + "SettingsAppearance", + "GitOverview", + ], + routeParamList: {}, + routeGetIdList: {}, +}; +type State = ReturnType; +type RouterAction = Parameters[1]; + +const threadA = { name: "Thread", params: { environmentId: "env", threadId: "a" } } as const; +const threadB = { name: "Thread", params: { environmentId: "env", threadId: "b" } } as const; +const pullRequest = { + name: "PullRequest", + params: { environmentId: "env", projectId: "p", repository: "o/r", number: 1, host: "h" }, +} as const; + +function apply(state: State, action: Parameters[1]) { + const next = router.getStateForAction(state, action, options); + if (!next) throw new Error(`Router rejected ${action.type}`); + // React Navigation rehydrates every state a router returns, as a stale RESET needs. + return base.getRehydratedState(next as State, options); +} + +function select( + state: State, + detail: WorkspaceDetail, + sheet?: Parameters[1], +) { + return apply(state, selectWorkspaceDetail(detail, sheet) as unknown as RouterAction); +} + +function push(state: State, name: string, params?: object) { + return apply(state, StackActions.push(name, params)); +} + +const home = () => base.getInitialState(options); +const page = (state: State) => ({ + names: state.routes.map((route) => route.name), + index: state.index, +}); + +describe("selectWorkspaceDetail", () => { + it("swaps the open thread in place instead of pushing another", () => { + const first = select(home(), threadA); + const second = select(first, threadB); + expect(page(second)).toEqual({ names: ["Home", "Thread"], index: 1 }); + expect(second.routes[1]?.key).toBe(first.routes[1]?.key); + expect(second.routes[1]?.params).toEqual(threadB.params); + }); + + it.each([ + ["a page drilled into from the thread", (state: State) => push(state, "ThreadFiles")], + ["a linked thread", (state: State) => push(state, "Thread", threadB.params)], + ["a pull request opened from the thread", (state: State) => push(state, "PullRequest")], + ["a sheet", (state: State) => push(state, "GitOverview")], + ])("leaves only the selection when %s is on top", (_, drillIn) => { + const thread = select(home(), threadA); + const selected = select(drillIn(thread), threadA); + expect(page(selected)).toEqual({ names: ["Home", "Thread"], index: 1 }); + expect(selected.routes[1]?.key).toBe(thread.routes[1]?.key); + }); + + it("replaces another tab's page and Settings with the selection", () => { + const settings = push(push(select(home(), threadA), "Settings"), "SettingsAppearance"); + const selected = select(settings, pullRequest); + expect(page(selected)).toEqual({ names: ["Home", "PullRequest"], index: 1 }); + expect(selected.routes[0]?.key).toBe(settings.routes[0]?.key); + }); + + it("puts Home under a cold-linked page and a sheet over the selection", () => { + const cold = apply(home(), { type: "RESET", payload: { index: 0, routes: [pullRequest] } }); + const selected = select(cold, pullRequest, { + name: "PullRequestReviewers", + params: pullRequest.params, + }); + expect(page(selected)).toEqual({ + names: ["Home", "PullRequest", "PullRequestReviewers"], + index: 2, + }); + }); + + it("never reuses the page native Back is popping", () => { + const thread = select(home(), threadA); + setNativelyPoppingWorkspaceRoute(thread.routes[1]!.key); + try { + const selected = select(thread, threadA); + expect(page(selected)).toEqual({ names: ["Home", "Thread"], index: 1 }); + expect(selected.routes[1]?.key).not.toBe(thread.routes[1]?.key); + } finally { + setNativelyPoppingWorkspaceRoute(null); + } + }); +}); + +describe("workspaceDetailLinkAction", () => { + it("selects a linked thread or pull request, and leaves other links alone", () => { + expect(workspaceDetailLinkAction({ routes: [{ name: "Home" }, threadA] })).toEqual( + selectWorkspaceDetail(threadA), + ); + expect(workspaceDetailLinkAction({ routes: [pullRequest] })).toEqual( + selectWorkspaceDetail(pullRequest), + ); + expect( + workspaceDetailLinkAction({ + routes: [{ name: "Home" }, { name: "Settings" }, { name: "SettingsAppearance" }], + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/features/workspace/workspace-detail-selection.ts b/apps/mobile/src/features/workspace/workspace-detail-selection.ts new file mode 100644 index 000000000000..70131d6681fc --- /dev/null +++ b/apps/mobile/src/features/workspace/workspace-detail-selection.ts @@ -0,0 +1,114 @@ +import type { + NavigationAction, + ParamListBase, + PartialState, + NavigationState, + Router, + StackNavigationState, +} from "@react-navigation/native"; +import { workspaceRoutesOf } from "../layout/workspace-overlay-routes"; + +type RootParams = ReactNavigation.RootParamList; + +/** A page the workspace's detail column shows on its own: what a list row selects. */ +export type WorkspaceDetail = + | { readonly name: "Thread"; readonly params: RootParams["Thread"] } + | { readonly name: "PullRequest"; readonly params: RootParams["PullRequest"] }; + +/** Android opens the reviewers sheet over the pull request in the same step. */ +export type WorkspaceDetailSheet = { + readonly name: "PullRequestReviewers"; + readonly params: RootParams["PullRequestReviewers"]; +}; + +const SELECT_WORKSPACE_DETAIL = "SELECT_WORKSPACE_DETAIL"; + +export interface SelectWorkspaceDetailAction { + readonly type: typeof SELECT_WORKSPACE_DETAIL; + readonly payload: { readonly detail: WorkspaceDetail; readonly sheet?: WorkspaceDetailSheet }; +} + +/** + * Shows `detail` as the workspace's only page, the way selecting a list row does. Drilling in + * from a page pushes instead. + */ +export function selectWorkspaceDetail( + detail: WorkspaceDetail, + sheet?: WorkspaceDetailSheet, +): SelectWorkspaceDetailAction { + return { type: SELECT_WORKSPACE_DETAIL, payload: sheet ? { detail, sheet } : { detail } }; +} + +const IDENTITY = { + Thread: ["environmentId", "threadId"], + PullRequest: ["environmentId", "projectId", "repository", "number", "host"], +} as const; + +type RouteLike = { readonly name: string; readonly key?: string; readonly params?: object }; + +function showsDetail(route: RouteLike, detail: WorkspaceDetail) { + if (route.name !== detail.name) return false; + const current = route.params as Readonly> | undefined; + const target = detail.params as Readonly> | undefined; + return IDENTITY[detail.name].every((key) => current?.[key] === target?.[key]); +} + +// UIKit pops a card by native Back before the router hears of it, so a row tapped meanwhile +// must not reuse the card on its way out. +let nativelyPoppingRoute: string | null = null; + +export function setNativelyPoppingWorkspaceRoute(key: string | null) { + nativelyPoppingRoute = key; +} + +/** + * The root stack after selecting `detail`: Home, then the detail alone. The route already showing + * it, or else the detail column's current page of the same kind, keeps its key, so the column + * updates in place instead of pushing. + */ +export function workspaceDetailState< + State extends { readonly index: number; readonly routes: readonly RouteLike[] }, +>(state: State, detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet, popping?: string | null) { + const { workspace } = workspaceRoutesOf(state.routes.slice(0, state.index + 1)); + const leaving = workspace.findIndex((route) => route.key !== undefined && route.key === popping); + const live = leaving < 0 ? workspace : workspace.slice(0, leaving); + const home = live.find((route) => route.name === "Home") ?? { name: "Home" }; + const page = live.find((route) => route.name !== "Home" && route.name !== "Settings"); + const reused = + live.findLast((route) => showsDetail(route, detail)) ?? + (page?.name === detail.name ? page : undefined); + const routes = [ + home, + reused ? { ...reused, params: detail.params } : { name: detail.name, params: detail.params }, + ...(sheet ? [sheet] : []), + ]; + return { ...state, index: routes.length - 1, routes }; +} + +/** Root stack router overrides that handle `selectWorkspaceDetail`. */ +export function workspaceDetailRouter( + original: Router, Action>, +): Partial, Action>> { + return { + getStateForAction(state, action, options) { + if (action.type !== SELECT_WORKSPACE_DETAIL) { + return original.getStateForAction(state, action, options); + } + const { detail, sheet } = (action as unknown as SelectWorkspaceDetailAction).payload; + const next = workspaceDetailState(state, detail, sheet, nativelyPoppingRoute); + const reset = { type: "RESET", payload: { ...next, stale: false } }; + return original.getStateForAction(state, reset as unknown as Action, options); + }, + }; +} + +/** A link to a single thread or pull request selects it, like its list row would. */ +export function workspaceDetailLinkAction( + state: PartialState | NavigationState, +): SelectWorkspaceDetailAction | undefined { + const pages = state.routes.filter((route) => route.name !== "Home"); + const [page] = pages; + if (pages.length !== 1 || !page || page.state) return undefined; + if (page.name !== "Thread" && page.name !== "PullRequest") return undefined; + return selectWorkspaceDetail({ name: page.name, params: page.params } as WorkspaceDetail); +} diff --git a/apps/mobile/src/features/workspace/workspace-selection-store.ts b/apps/mobile/src/features/workspace/workspace-selection-store.ts index 52c11038719b..aaabc0f1715e 100644 --- a/apps/mobile/src/features/workspace/workspace-selection-store.ts +++ b/apps/mobile/src/features/workspace/workspace-selection-store.ts @@ -46,12 +46,25 @@ function save() { .catch(() => undefined); } +// Route keys only name routes of the session that wrote them; a saved one would make a tab rebuild +// its page under a stale key, which UIKit animates as a pop and a push. +function withoutRouteKeys(saved: WorkspaceSelections | undefined): WorkspaceSelections { + const omitKey = ({ key: _, ...rest }: Selection) => + rest; + const { threads, pullRequests, settingsKey: _, ...rest } = saved ?? {}; + return { + ...rest, + ...(threads ? { threads: omitKey(threads) } : {}), + ...(pullRequests ? { pullRequests: omitKey(pullRequests) } : {}), + }; +} + /** Loads the saved selections once; pages visited before it resolves win over saved ones. */ export function hydrateWorkspaceSelections() { if (!REMEMBERS) return Promise.resolve(); hydration ??= loadPreferences() .then((preferences) => { - selections = { ...preferences.workspaceSelections, ...selections }; + selections = { ...withoutRouteKeys(preferences.workspaceSelections), ...selections }; saved = selections; AppState.addEventListener("change", (status) => { if (status !== "active") save(); @@ -65,7 +78,10 @@ export function currentWorkspaceSelections() { return selections; } -/** The navigation container's `onStateChange`: remembers the page each tab is showing. */ +/** + * The navigation container's `onReady` and `onStateChange`: remembers the page each tab is + * showing. The initial state, such as a cold start's reopened chat, reaches only `onReady`. + */ export function recordWorkspaceSelections(state: NavigationState | undefined) { if (!REMEMBERS || !state) return; selections = workspaceSelectionsIn(state.routes.slice(0, state.index + 1), selections); diff --git a/apps/mobile/src/lib/adaptive-navigation.test.ts b/apps/mobile/src/lib/adaptive-navigation.test.ts deleted file mode 100644 index 320b92a803eb..000000000000 --- a/apps/mobile/src/lib/adaptive-navigation.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - resolveThreadSelectionNavigationAction, - resolveThreadSelectionOverlayState, -} from "./adaptive-navigation"; - -describe("resolveThreadSelectionNavigationAction", () => { - it("retains the draft screen despite its query-shaped path", () => { - expect( - resolveThreadSelectionNavigationAction({ - usesSplitView: true, - pathname: "/threads?draft=local", - workspaceRouteName: "Thread", - }), - ).toBe("set-params"); - }); - it("replaces nested thread content when a persistent sidebar selects a peer", () => { - expect( - resolveThreadSelectionNavigationAction({ - usesSplitView: true, - pathname: "/threads/environment/thread/files/path", - }), - ).toBe("replace"); - }); - - it("pushes from Home so the back stack survives collapsing to compact", () => { - expect( - resolveThreadSelectionNavigationAction({ - usesSplitView: true, - pathname: "/", - }), - ).toBe("push"); - }); -}); - -describe("resolveThreadSelectionOverlayState", () => { - const stack = { - key: "workspace", - type: "stack", - stale: false as const, - routeNames: ["Home", "Thread", "ThreadFiles", "Connections", "SettingsLegal"], - }; - const home = { key: "home", name: "Home" }; - const thread = { - key: "thread", - name: "Thread", - params: { environmentId: "environment", threadId: "old-thread" }, - }; - const files = { key: "files", name: "ThreadFiles", params: thread.params }; - const settings = { key: "settings", name: "Connections" }; - const params = { environmentId: "environment", threadId: "new-thread" }; - - it("replaces the underlying file route and dismisses every overlay above it", () => { - expect( - resolveThreadSelectionOverlayState({ - state: { - ...stack, - index: 4, - routes: [home, thread, files, settings, { key: "legal", name: "SettingsLegal" }], - }, - workspaceRouteKey: files.key, - action: "replace", - params, - }), - ).toEqual({ ...stack, index: 2, routes: [home, thread, { name: "Thread", params }] }); - }); - - it("dismisses an overlay when selecting the current thread without replacing its route key", () => { - expect( - resolveThreadSelectionOverlayState({ - state: { ...stack, index: 2, routes: [home, thread, settings] }, - workspaceRouteKey: thread.key, - action: "set-params", - params: thread.params, - }), - ).toEqual({ ...stack, index: 1, routes: [home, thread] }); - }); - - it.each([home, files])( - "keeps $name in the back stack when pushing from beneath a sheet", - (route) => { - expect( - resolveThreadSelectionOverlayState({ - state: { ...stack, index: 1, routes: [route, settings] }, - workspaceRouteKey: route.key, - action: "push", - params, - }), - ).toEqual({ ...stack, index: 1, routes: [route, { name: "Thread", params }] }); - }, - ); -}); - -it("replacing a draft underneath an overlay removes its draft identity", () => { - const result = resolveThreadSelectionOverlayState({ - state: { - key: "root", - type: "stack", - stale: false, - routeNames: ["Thread", "Connections"], - index: 1, - routes: [ - { key: "draft", name: "Thread", params: { draft: { draftId: "old" } } }, - { key: "settings", name: "Connections" }, - ], - }, - workspaceRouteKey: "draft", - action: "set-params", - params: { environmentId: "env", threadId: "live" }, - }); - expect(result?.routes[0]?.params).toEqual({ environmentId: "env", threadId: "live" }); -}); diff --git a/apps/mobile/src/lib/adaptive-navigation.ts b/apps/mobile/src/lib/adaptive-navigation.ts deleted file mode 100644 index d19739369ef6..000000000000 --- a/apps/mobile/src/lib/adaptive-navigation.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { NavigationState } from "@react-navigation/native"; - -export type AdaptiveNavigationAction = "push" | "replace" | "set-params"; - -const BASE_THREAD_ROUTE_PATTERN = /^\/threads\/[^/]+\/[^/]+\/?$/; - -export function isBaseThreadRoute(pathname: string): boolean { - return BASE_THREAD_ROUTE_PATTERN.test(pathname) || /^\/new\/draft\/?$/.test(pathname); -} - -/** Push from Home so it stays beneath Thread when split view collapses. */ -export function resolveThreadSelectionNavigationAction(input: { - readonly usesSplitView: boolean; - readonly pathname: string; - readonly workspaceRouteName?: string; -}): AdaptiveNavigationAction { - if (!input.usesSplitView || input.pathname === "/") { - return "push"; - } - - return input.workspaceRouteName === "Thread" || isBaseThreadRoute(input.pathname) - ? "set-params" - : "replace"; -} - -export function resolveThreadSelectionOverlayState(input: { - readonly state: NavigationState | undefined; - readonly workspaceRouteKey: string | undefined; - readonly action: AdaptiveNavigationAction; - readonly params: ReactNavigation.RootParamList["Thread"]; -}) { - if (input.state === undefined) return null; - const workspaceIndex = input.state.routes.findIndex( - (route) => route.key === input.workspaceRouteKey, - ); - if (workspaceIndex < 0 || workspaceIndex >= input.state.index) return null; - - const workspaceRoute = input.state.routes[workspaceIndex]; - const routes = input.state.routes.slice(0, workspaceIndex + (input.action === "push" ? 1 : 0)); - return { - ...input.state, - index: routes.length, - routes: [ - ...routes, - input.action === "set-params" && workspaceRoute?.name === "Thread" - ? { ...workspaceRoute, params: input.params } - : { name: "Thread", params: input.params }, - ], - }; -} - -export function resolveFileSelectionNavigationAction(input: { - readonly hasPersistentFileInspector: boolean; -}): AdaptiveNavigationAction { - return input.hasPersistentFileInspector ? "replace" : "push"; -} diff --git a/apps/mobile/src/lib/android-workspace-navigation.test.ts b/apps/mobile/src/lib/android-workspace-navigation.test.ts index c55c3a9e98d6..822ea6944349 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.test.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.test.ts @@ -11,7 +11,6 @@ import { parentThreadIsBelow, resetAndroidWorkspace, returnToAndroidWorkspaceList, - type AndroidWorkspaceDestination, } from "./android-workspace-navigation"; import type { RootStackResetAction } from "./root-stack-reset"; @@ -69,15 +68,8 @@ function apply( return router.getRehydratedState(result, options); } -function select( - state: ReturnType, - destination?: AndroidWorkspaceDestination, - reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], -) { - return apply( - state, - CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), - ); +function withThread() { + return apply(router.getInitialState(options), StackActions.push(thread.name, thread.params)); } function legacyHistory() { @@ -92,69 +84,8 @@ function names(state: ReturnType) { } describe("Android workspace navigation", () => { - it("replaces cross-tab history with one detail and returns directly to its list", () => { - const previous = legacyHistory(); - const state = select(previous, pullRequest); - expect(names(state)).toEqual(["Home", "PullRequest"]); - expect(state.routes[0]?.key).toBe(previous.routes[0]?.key); - expect(state.routes[1]?.key).toBe(previous.routes[2]?.key); - expect(names(apply(state, StackActions.pop()))).toEqual(["Home"]); - }); - - it("replaces peer thread selections without leaving the previous thread underneath", () => { - const first = select(router.getInitialState(options), thread); - const second = select(first, { - name: "Thread", - params: { ...thread.params, threadId: "thread-b" }, - }); - expect(names(second)).toEqual(["Home", "Thread"]); - expect(second.routes[1]?.params).toEqual({ ...thread.params, threadId: "thread-b" }); - expect(second.routes[1]?.key).not.toBe(first.routes[1]?.key); - expect(names(apply(second, StackActions.pop()))).toEqual(["Home"]); - }); - - it("creates Home beneath cold-linked details and gives direct list escape", () => { - const initial = router.getInitialState(options); - const cold = apply(initial, CommonActions.reset({ index: 0, routes: [pullRequest] })); - expect(names(select(cold, pullRequest))).toEqual(["Home", "PullRequest"]); - expect(names(select(cold))).toEqual(["Home"]); - }); - - it("keeps a request-review flow above the chosen PR instead of an old chat", () => { - const state = select(legacyHistory(), pullRequest, pullRequest.params); - expect(names(state)).toEqual(["Home", "PullRequest", "PullRequestReviewers"]); - const back = apply(state, StackActions.pop()); - expect(names(back)).toEqual(["Home", "PullRequest"]); - expect(back.routes[back.index]?.params).toEqual(pullRequest.params); - }); - - it.each(["ThreadFiles", "ThreadTerminal", "GitOverview"])( - "returns to chat when its sidebar row is selected from %s", - (route) => { - const detail = select(router.getInitialState(options), thread); - const nested = apply(detail, StackActions.push(route, thread.params)); - const selected = select(nested, thread); - expect(names(selected)).toEqual(["Home", "Thread"]); - expect(selected.routes[1]?.key).toBe(detail.routes[1]?.key); - expect(names(apply(selected, StackActions.pop()))).toEqual(["Home"]); - }, - ); - - it.each([{ environmentId: "environment-b" }, { repository: "owner/other" }])( - "does not reuse another PR's key when its scope differs: %o", - (change) => { - const previous = select(router.getInitialState(options), pullRequest); - const state = select(previous, { - name: "PullRequest", - params: { ...pullRequest.params, ...change }, - }); - expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key); - expect(state.routes[1]?.params).toEqual({ ...pullRequest.params, ...change }); - }, - ); - it("returns to a distinct previous thread, otherwise to the workspace list", () => { - const parent = select(router.getInitialState(options), thread); + const parent = withThread(); let state = apply( parent, StackActions.push("Thread", { ...thread.params, threadId: "child-a" }), @@ -206,7 +137,7 @@ describe("Android workspace navigation", () => { }); it("opens a linked thread above the current one so Back returns to it", () => { - const parent = select(router.getInitialState(options), thread); + const parent = withThread(); const child = { ...thread.params, threadId: "child-a" }; const open = ( state: ReturnType, @@ -230,7 +161,7 @@ describe("Android workspace navigation", () => { }); it("recognizes a parent only when its environment and thread are immediately below", () => { - const parent = select(router.getInitialState(options), thread); + const parent = withThread(); const child = apply( parent, StackActions.push("Thread", { ...thread.params, threadId: "child-a" }), @@ -259,35 +190,29 @@ describe("Android workspace navigation", () => { getParent: () => undefined, getState: () => root, }; - resetAndroidWorkspace( - { - dispatch: (action) => actions.push(action), - getParent: () => rootNavigation, - getState: () => nested, - }, - thread, - ); + resetAndroidWorkspace({ + dispatch: (action) => actions.push(action), + getParent: () => rootNavigation, + getState: () => nested, + }); const [action] = actions; expect(action?.target).toBe(root.key); - expect(names(apply(root, action!))).toEqual(["Home", "Thread"]); + expect(names(apply(root, action!))).toEqual(["Home"]); }); it("resets from the navigation container before any navigation action", () => { const root = router.getInitialState(options); const actions: RootStackResetAction[] = []; // The container only learns the root state after the first action. - resetAndroidWorkspace( - { - dispatch: (action) => actions.push(action), - getParent: () => undefined, - getState: () => undefined, - getRootState: () => root, - }, - thread, - ); + resetAndroidWorkspace({ + dispatch: (action) => actions.push(action), + getParent: () => undefined, + getState: () => undefined, + getRootState: () => root, + }); const [action] = actions; expect(action?.target).toBe(root.key); - expect(names(apply(root, action!))).toEqual(["Home", "Thread"]); + expect(names(apply(root, action!))).toEqual(["Home"]); }); }); diff --git a/apps/mobile/src/lib/android-workspace-navigation.ts b/apps/mobile/src/lib/android-workspace-navigation.ts index 0c5408d82b6a..3c2a995e41a6 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.ts @@ -1,10 +1,6 @@ import type { NavigationState } from "@react-navigation/native"; import { resetRootStack, type RootStackNavigation } from "./root-stack-reset"; -export type AndroidWorkspaceDestination = - | { readonly name: "Thread"; readonly params: ReactNavigation.RootParamList["Thread"] } - | { readonly name: "PullRequest"; readonly params: ReactNavigation.RootParamList["PullRequest"] }; - function androidWorkspaceBackReturnsToThread(state: NavigationState) { const current = state.routes[state.index]; const params = current?.params as ReactNavigation.RootParamList["Thread"] | undefined; @@ -16,43 +12,14 @@ function androidWorkspaceBackReturnsToThread(state: NavigationState) { ); } -export function androidWorkspaceNavigationState( - state: NavigationState, - destination?: AndroidWorkspaceDestination, - reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], -) { +/** The root stack with only the workspace list. */ +export function androidWorkspaceNavigationState(state: NavigationState) { const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" }; - if (!destination) return { ...state, index: 0, routes: [home] }; - const identity = - destination.name === "Thread" - ? ["environmentId", "threadId"] - : ["environmentId", "projectId", "repository", "number", "host"]; - const params = destination.params as Readonly>; - const existing = state.routes.findLast((route) => { - if (route.name !== destination.name) return false; - const current = route.params as Readonly> | undefined; - return identity.every((key) => current?.[key] === params[key]); - }); - return { - ...state, - index: reviewers ? 2 : 1, - routes: [ - home, - { ...existing, ...destination }, - ...(reviewers ? [{ name: "PullRequestReviewers", params: reviewers }] : []), - ], - }; + return { ...state, index: 0, routes: [home] }; } -/** Resets the root stack to a workspace list or detail. */ -export function resetAndroidWorkspace( - navigation: RootStackNavigation, - destination?: AndroidWorkspaceDestination, - reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], -) { - resetRootStack(navigation, (state) => - androidWorkspaceNavigationState(state, destination, reviewers), - ); +export function resetAndroidWorkspace(navigation: RootStackNavigation) { + resetRootStack(navigation, androidWorkspaceNavigationState); } export function parentThreadIsBelow( diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx index 0597e6b07772..a9ad443aa4a3 100644 --- a/apps/mobile/src/native/createV5StackNavigator.ios.tsx +++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx @@ -54,12 +54,24 @@ export function modalEnvelopeOptions(options: NativeStackNavigationOptions) { /** Keep outgoing screens until UIKit completes its pop, as required by v5. */ export function V5CardStackView( - props: V5StackViewProps & { readonly onRootVisibilityChange?: (visible: boolean) => void }, + props: V5StackViewProps & { + readonly onRootVisibilityChange?: (visible: boolean) => void; + /** The card native Back is popping, which the router still shows until the pop finishes. */ + readonly onNativePopChange?: (key: string | null) => void; + }, ) { const { preventedRoutes } = usePreventRemoveContext(); // Options a screen set inside this workspace tab slot, over its route's. const slot = use(WorkspaceSlotOptionsContext); const nativePopSource = useRef(null); + const { onNativePopChange } = props; + const setNativePopSource = useCallback( + (key: string | null) => { + nativePopSource.current = key; + onNativePopChange?.(key); + }, + [onNativePopChange], + ); const [screens, setScreens] = useState({ completedNativeDismissals: new Set(), nativeCompletingPops: new Set(), @@ -121,11 +133,11 @@ export function V5CardStackView( descriptors, }; }); - if (nativePopSource.current === key) nativePopSource.current = null; + if (nativePopSource.current === key) setNativePopSource(null); const action = nativeStackPopAction(state, key); if (action) props.navigation.dispatch(action); }, - [props.navigation, setScreens], + [props.navigation, setNativePopSource, setScreens], ); const removeDismissed = useCallback( (key: string) => { @@ -177,7 +189,7 @@ export function V5CardStackView( onDidAppear={() => { const source = nativePopSource.current; if (source === route.key) { - nativePopSource.current = null; + setNativePopSource(null); } else if (source) { const state = props.navigation.getState(); const action = nativeStackPopAction(state, source, route.key); @@ -194,7 +206,7 @@ export function V5CardStackView( ...poppedKeys, ]), })); - nativePopSource.current = null; + setNativePopSource(null); props.navigation.dispatch(action); } } @@ -209,7 +221,7 @@ export function V5CardStackView( const state = props.navigation.getState(); // Only native Back leaves the disappearing card active; JS navigation already changed history. if (state.index > 0 && state.routes[state.index]?.key === route.key) { - nativePopSource.current = route.key; + setNativePopSource(route.key); } props.navigation.emit({ type: "transitionStart", diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index 4e71aa5fde14..4ce5b4e876aa 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -2,7 +2,7 @@ import { useNavigation, useRoute, StackActions } from "@react-navigation/native" import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; import { useCallback } from "react"; import { Platform } from "react-native"; -import { resetAndroidWorkspace } from "../lib/android-workspace-navigation"; +import { useSelectWorkspaceDetail } from "../features/workspace/useSelectWorkspaceDetail"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useProjects, useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; @@ -14,6 +14,7 @@ type PullRequestContext = { readonly environmentId: string; readonly projectId: // Avoid useRoute so layouts without route context can open any thread's pull requests. export function useNavigateToPullRequest(options?: { readonly replace?: boolean }) { const navigation = useNavigation(); + const selectDetail = useSelectWorkspaceDetail(); const projects = useProjects(); const replace = options?.replace; return useCallback( @@ -27,13 +28,13 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean (environmentId) => serverConfigs.get(environmentId)?.environment.capabilities, ); if (!params) return tryOpenExternalUrl(url, "pull-request"); - if (Platform.OS === "android") - resetAndroidWorkspace(navigation, { name: "PullRequest", params }); + // Android shows one page at a time; iOS opens it over the current page, so Back returns. + if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); - else navigation.navigate("PullRequest", params); + else navigation.dispatch(StackActions.push("PullRequest", params)); return true; }, - [navigation, projects, replace], + [navigation, projects, replace, selectDetail], ); } diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index 2e48739e456f..cf4dea315940 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -5,6 +5,32 @@ so UIKit can animate the header within one navigation controller. The iPad sideb has its own stack. Splitting Home and threads between controllers loses that continuous transition. +## Selecting versus drilling in + +Opening a page has two intents, and each has one action: + +- **Select** shows a thread or pull request as the workspace's only page, the + way a list row does. Everything that selects (rows, the command palette, + compose, notifications, deep links) uses + [`selectWorkspaceDetail`](../../apps/mobile/src/features/workspace/workspace-detail-selection.ts), + which the root stack's router handles. The resulting stack is the same on + phones, collapsed splits, and expanded splits. Only route keys differ: the + page already showing keeps its key, so the detail column updates in place + instead of pushing. +- **Drill in** opens a page over the current one so Back returns, with + `StackActions.push`. + +`navigate` to `Thread` or `PullRequest` is rejected by lint, because it pushes +or updates in place depending on what happens to be on top. Never infer the +choice from transition events: UIKit reports the sidebar column appearing in +split views too. The one native signal the router uses is the card native Back +is popping. The workspace navigator reports it, and a selection never reuses +that card. + +Beside the list, Settings pages share one native screen whose content swaps, +like the system Settings app. A route per page would pop and push the column, +and would unmount its header configuration, which hides the navigation bar. + ## UIKit constraints The [react-native-screens patch](../../patches/react-native-screens@4.26.2.patch) diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 43c7a93eee83..999808515fc5 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -12,6 +12,7 @@ import noLowValueComments from "./rules/no-low-value-comments.ts"; import noManualMemo from "./rules/no-manual-memo.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; +import noAmbiguousDetailNavigation from "./rules/no-ambiguous-detail-navigation.ts"; import noMobilePullRequestGlyphLiterals from "./rules/no-mobile-pull-request-glyph-literals.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts"; @@ -47,6 +48,7 @@ export default definePlugin({ "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-manual-memo": noManualMemo, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, + "no-ambiguous-detail-navigation": noAmbiguousDetailNavigation, "no-mobile-pull-request-glyph-literals": noMobilePullRequestGlyphLiterals, "no-native-title-tooltip": noNativeTitleTooltip, "no-outset-state-indicators": noOutsetStateIndicators, diff --git a/oxlint-plugin-t3code/rules/no-ambiguous-detail-navigation.test.ts b/oxlint-plugin-t3code/rules/no-ambiguous-detail-navigation.test.ts new file mode 100644 index 000000000000..bfa35ddff5e5 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-ambiguous-detail-navigation.test.ts @@ -0,0 +1,25 @@ +import { describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-ambiguous-detail-navigation", { + filename: "apps/mobile/src/features/threads/ThreadFeed.tsx", +}); + +describe("t3code/no-ambiguous-detail-navigation", () => { + rule.valid( + "allows selections, explicit pushes, and other routes", + ` + selectDetail({ name: "Thread", params }); + navigation.dispatch(StackActions.push("PullRequest", params)); + navigation.navigate("ThreadFiles", params); + `, + ); + + rule.invalid("reports navigating to a thread", `navigation.navigate("Thread", params);`); + + rule.invalid( + "reports a navigate action to a pull request", + `navigation.dispatch(CommonActions.navigate("PullRequest", params));`, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-ambiguous-detail-navigation.ts b/oxlint-plugin-t3code/rules/no-ambiguous-detail-navigation.ts new file mode 100644 index 000000000000..f3db23e2b119 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-ambiguous-detail-navigation.ts @@ -0,0 +1,35 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; + +const DETAIL_ROUTES = new Set(["Thread", "PullRequest"]); + +const MESSAGE = + "`navigate` to a Thread or PullRequest pushes or updates in place depending on what is on top. Use useSelectWorkspaceDetail to show it as the page, or StackActions.push to open it over the current one."; + +function calleeName(callee: ESTree.Expression) { + if (callee.type === "Identifier") return callee.name; + if (callee.type === "MemberExpression" && callee.property.type === "Identifier") { + return callee.property.name; + } + return null; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow `navigate` to the workspace's detail routes; selections and drill-ins each have one explicit action.", + }, + }, + create(context) { + return { + CallExpression(node) { + if (calleeName(node.callee as ESTree.Expression) !== "navigate") return; + const [route] = node.arguments; + if (route?.type !== "Literal" || typeof route.value !== "string") return; + if (!DETAIL_ROUTES.has(route.value)) return; + context.report({ node, message: MESSAGE }); + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index b8ee4011ca64..8495e43d6dce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -435,6 +435,11 @@ export default defineConfig({ ], rules: { "t3code/no-mobile-pull-request-glyph-literals": "error" }, }, + { + files: ["apps/mobile/src/**"], + excludeFiles: ["**/*.test.{ts,tsx}"], + rules: { "t3code/no-ambiguous-detail-navigation": "error" }, + }, { // Raw ResizeObserver state commits miss the paint; observeResize flushes derived layout together. files: ["apps/web/src/**"], From c83342d1eccd3eb2b87a36b1814db78be921849c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 22:48:03 -0700 Subject: [PATCH 10/34] fix(mobile): Settings pages no longer flicker when switching beside the list (#24749) Co-authored-by: Claude Opus 5.5 --- .../layout/createWorkspaceStackNavigator.ios.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 61075bad190c..d9c233a90f35 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -19,7 +19,16 @@ import { type NativeStackNavigatorProps, type NativeStackTypeBag, } from "@react-navigation/native-stack"; -import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; +import { + Fragment, + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; import { View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -158,14 +167,14 @@ function ColumnScreen(props: { } /> - {descriptor.render()} + {/* The Settings slot swaps pages here; its column keeps its measured insets. */} + {descriptor.render()} From c79ef8c454e1268ff79da5a9dcb74572e86f7861 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 22:48:48 -0700 Subject: [PATCH 11/34] fix(server): use Codex native collaboration modes (#24737) Co-authored-by: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 36 +++++ .../Adapters/CodexAdapterV2.ts | 33 ++-- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 8 +- .../message_steering/codex_transcript.ndjson | 2 +- .../multi_turn/codex_transcript.ndjson | 4 +- .../plan_questions/codex_transcript.ndjson | 2 +- .../proposed_plan/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 4 +- .../queued_turn/codex_transcript.ndjson | 4 +- .../fixtures/simple/codex_transcript.ndjson | 2 +- .../fixtures/subagent/codex_transcript.ndjson | 2 +- .../subagent_continue/codex_transcript.ndjson | 4 +- .../subagent_v2/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 6 +- .../codex_transcript.ndjson | 6 +- .../codex_transcript.ndjson | 6 +- .../codex_transcript.ndjson | 12 +- .../thread_rollback/codex_transcript.ndjson | 6 +- .../codex_transcript.ndjson | 6 +- .../codex_transcript.ndjson | 8 +- .../todo_list/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../turn_interrupt/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../web_search/codex_transcript.ndjson | 2 +- .../CodexDeveloperInstructions.test.ts | 2 +- .../provider/CodexDeveloperInstructions.ts | 150 ------------------ 33 files changed, 100 insertions(+), 229 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index a40f6c07947a..bd7b6f420876 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -134,6 +134,40 @@ describe("CodexAdapterV2 assistant message streaming", () => { }); describe("CodexAdapterV2 runtime policy", () => { + it.effect.each([undefined, false, true])( + "selects native collaboration modes without prompt overrides with hasT3Mcp=%s", + (hasT3Mcp) => + Effect.gen(function* () { + for (const interactionMode of ["plan", "default"] as const) { + for (const reasoningEffort of [undefined, "high"] as const) { + const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-mode-switch", + codexInput: [{ type: "text", text: "test" }], + runtimePolicy: { runtimeMode: "full-access", interactionMode, cwd: null }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + ...(reasoningEffort === undefined + ? {} + : { options: [{ id: "reasoningEffort", value: reasoningEffort }] }), + }, + ...(hasT3Mcp === undefined ? {} : { hasT3Mcp }), + }); + + assert.deepEqual(params.collaborationMode, { + mode: interactionMode, + settings: { + model: "gpt-5.4", + ...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }), + }, + }); + assert.equal(params.effort, reasoningEffort); + assert.equal(params.additionalContext !== undefined, hasT3Mcp === true); + } + } + }), + ); + it.effect("derives concrete Codex turn policies from every T3 runtime mode", () => Effect.gen(function* () { const build = ( @@ -620,6 +654,7 @@ function codexReplayPreamble(input: { approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess" }, summary: "detailed", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, }, }, }, @@ -1514,6 +1549,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { model: "gpt-5.4", ...turnPolicies[turnModes[index]!], summary: "detailed", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, }, }, }, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 0b9d8de53331..21c9943b4119 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -98,10 +98,7 @@ import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachme import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import { ServerConfig } from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; -import { - buildCodexAdditionalContext, - buildCodexDeveloperInstructions, -} from "../../provider/CodexDeveloperInstructions.ts"; +import { buildCodexAdditionalContext } from "../../provider/CodexDeveloperInstructions.ts"; import { describeMcpElicitation, toMcpElicitationResponse, @@ -756,10 +753,6 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); - const developerInstructions = - input.hasT3Mcp !== true - ? undefined - : buildCodexDeveloperInstructions(input.runtimePolicy.interactionMode); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -781,21 +774,6 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur t3Context === undefined && Object.keys(appContext).length === 0 ? undefined : { ...t3Context, ...appContext }; - const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined = - input.runtimePolicy.interactionMode !== "plan" && developerInstructions === undefined - ? undefined - : { - mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "default", - settings: { - model: input.modelSelection.model, - // Codex falls back to the model's default effort when this is left out. - ...(effort === undefined ? {} : { reasoning_effort: effort }), - ...(developerInstructions === undefined - ? {} - : { developer_instructions: developerInstructions }), - }, - }; - return yield* decodeCodexTurnStartParamsWithCollaborationMode({ threadId: input.nativeThreadId, input: input.codexInput, @@ -810,7 +788,14 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), ...(effort === undefined ? {} : { effort }), ...(serviceTier === undefined ? {} : { serviceTier }), - ...(collaborationMode === undefined ? {} : { collaborationMode }), + // Codex supplies its own mode instructions; send both modes to clear Plan on exit. + collaborationMode: { + mode: input.runtimePolicy.interactionMode, + settings: { + model: input.modelSelection.model, + ...(effort === undefined ? {} : { reasoning_effort: effort }), + }, + }, }); }, ); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/codex_transcript.ndjson index abe0b1b1f650..50dd654ed405 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/codex_transcript.ndjson @@ -5,7 +5,7 @@ {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000},"cwd":"","model":"gpt-6-luna"}}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1791001627148}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a10004-0a27-7421-852d-0f7f99ace2d6","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a10004-0a27-7421-852d-0f7f99ace2d6","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"cpa","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1791001627,"updatedAt":1791001627,"recencyAt":1791001627,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/10/03/rollout-2026-10-03T00-27-07-01a10004-0a27-7421-852d-0f7f99ace2d6.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.157.0","originator":"T3 Code","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"cpa","serviceTier":"default","disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"readOnly","networkAccess":false},"activePermissionProfile":{"id":":read-only","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact shell command in the foreground and wait for it to finish: `for i in 1 2 3 4; do printf '\\033[32mstream line %s\\033[0m\\n' $i; sleep 1; done; printf 'progress 50%%\\rprogress 100%%\\n'`. Then respond exactly: command output fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact shell command in the foreground and wait for it to finish: `for i in 1 2 3 4; do printf '\\033[32mstream line %s\\033[0m\\n' $i; sleep 1; done; printf 'progress 50%%\\rprogress 100%%\\n'`. Then respond exactly: command output fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a10004-0a27-7421-852d-0f7f99ace2d6","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a10004-0a27-7421-852d-0f7f99ace2d6","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"cpa","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1791001627,"updatedAt":1791001627,"recencyAt":1791001627,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/10/03/rollout-2026-10-03T00-27-07-01a10004-0a27-7421-852d-0f7f99ace2d6.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.157.0","originator":"T3 Code","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1791001627410}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","name":"cua_repl","status":"starting","error":null,"failureReason":null},"emittedAtMs":1791001627411}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1791001627411}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson index 7df1add6f9c0..52c196b151ab 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson @@ -4,14 +4,14 @@ {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} {"type":"expect_outbound","label":"parent.thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000}}}} {"type":"emit_inbound","label":"parent.thread/start","frame":{"id":2,"result":{"thread":{"id":"019f1000-0000-7000-8000-000000000001","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739467,"updatedAt":1776739467,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-parent.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} -{"type":"expect_outbound","label":"parent.turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Keep this parent turn active while orchestration tools are tested.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000001","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"expect_outbound","label":"parent.turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Keep this parent turn active while orchestration tools are tested.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000001","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"parent.thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"019f1000-0000-7000-8000-000000000001","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739467,"updatedAt":1776739467,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-parent.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]}}}} {"type":"emit_inbound","label":"parent.turn/start","frame":{"id":3,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000002","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"parent.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000001","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"parent.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000001","turn":{"id":"019f1000-0000-7000-8000-000000000002","items":[],"status":"inProgress","error":null,"startedAt":1776739467,"completedAt":null,"durationMs":null}}}} {"type":"expect_outbound","label":"child.thread/start","frame":{"id":4,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000}}}} {"type":"emit_inbound","label":"child.thread/start","frame":{"id":4,"result":{"thread":{"id":"019f1000-0000-7000-8000-000000000003","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739468,"updatedAt":1776739468,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-child.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} -{"type":"expect_outbound","label":"child.original.turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Inspect the delegated API boundary and return the result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"expect_outbound","label":"child.original.turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Inspect the delegated API boundary and return the result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"child.thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"019f1000-0000-7000-8000-000000000003","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739468,"updatedAt":1776739468,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-child.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]}}}} {"type":"emit_inbound","label":"child.original.turn/start","frame":{"id":5,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000004","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.original.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} @@ -21,7 +21,7 @@ {"type":"emit_inbound","label":"child.original.item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_delegated_task_status_original","text":"Delegated API boundary inspected.","phase":"final_answer","memoryCitation":null},"threadId":"019f1000-0000-7000-8000-000000000003","turnId":"019f1000-0000-7000-8000-000000000004"}}} {"type":"emit_inbound","label":"child.original.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"child.original.turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000004","items":[],"status":"completed","error":null,"startedAt":1776739468,"completedAt":1776739469,"durationMs":1000}}}} -{"type":"expect_outbound","label":"child.running.turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Remain active until the parent cancels this delegated task.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"expect_outbound","label":"child.running.turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Remain active until the parent cancels this delegated task.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"child.running.turn/start","frame":{"id":6,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.running.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"child.running.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"inProgress","error":null,"startedAt":1776739470,"completedAt":null,"durationMs":null}}}} @@ -29,7 +29,7 @@ {"type":"emit_inbound","label":"child.running.turn/interrupt","frame":{"id":7,"result":{}}} {"type":"emit_inbound","label":"child.running.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"child.running.turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"interrupted","error":null,"startedAt":1776739470,"completedAt":1776739471,"durationMs":1000}}}} -{"type":"expect_outbound","label":"child.queued.turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Complete the queued follow-up and return the final result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"expect_outbound","label":"child.queued.turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Complete the queued follow-up and return the final result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"child.queued.turn/start","frame":{"id":8,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000006","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.queued.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"child.queued.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000006","items":[],"status":"inProgress","error":null,"startedAt":1776739472,"completedAt":null,"durationMs":null}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson index e9c0f9e44ef4..c265d86a89f6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295500250}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295500250}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295500,"updatedAt":1790295500,"recencyAt":1790295500,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-20-01a0d5ed-65ea-7d42-b8e9-d06f045cfa86.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: steering fixture initial response","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: steering fixture initial response","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295500,"updatedAt":1790295500,"recencyAt":1790295500,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-20-01a0d5ed-65ea-7d42-b8e9-d06f045cfa86.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295500547}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295500547}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295500567}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson index bf8d8630b854..bc288508adbf 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295480414}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295480414}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295480,"updatedAt":1790295480,"recencyAt":1790295480,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-00-01a0d5ed-187d-70c2-b4b6-b6881a68de09.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295480,"updatedAt":1790295480,"recencyAt":1790295480,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-00-01a0d5ed-187d-70c2-b4b6-b6881a68de09.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295480656}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295480656}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295480666}} @@ -27,7 +27,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","tokenUsage":{"total":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295485211}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"idle"}},"emittedAtMs":1790295485220}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","text":"first fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295480,"completedAt":1790295485,"durationMs":4550}},"emittedAtMs":1790295485220}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"xcodebuildmcp","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295485265}} {"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295485286}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson index 9a1cf76a2f1a..d6265ff63780 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295620180}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295620180}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295620,"updatedAt":1790295620,"recencyAt":1790295620,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-20-01a0d5ef-3a6a-75d0-9942-61b29ab82328.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","collaborationMode":{"mode":"plan","settings":{"developer_instructions":"You are in Plan mode. Prefer request_user_input for clarifying questions. When presenting a complete plan, wrap it in and .","model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295620428}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295620,"updatedAt":1790295620,"recencyAt":1790295620,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-20-01a0d5ef-3a6a-75d0-9942-61b29ab82328.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295620428}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":"medium","summary":"detailed","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna","reasoning_effort":"medium","developer_instructions":"You are in Plan mode. Prefer request_user_input for clarifying questions. When presenting a complete plan, wrap it in and ."}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295620457}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson index d0911eddbe76..9c2bf20ecf2e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295608195}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295608195}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295608,"updatedAt":1790295608,"recencyAt":1790295608,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-08-01a0d5ef-0ba3-7552-b090-7cfbb328e278.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Create a short implementation plan for adding deterministic replay fixtures. Do not ask questions. Present the final plan in a proposed plan block.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","collaborationMode":{"mode":"plan","settings":{"developer_instructions":"You are in Plan mode. Prefer request_user_input for clarifying questions. When presenting a complete plan, wrap it in and .","model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Create a short implementation plan for adding deterministic replay fixtures. Do not ask questions. Present the final plan in a proposed plan block.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295608443}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295608,"updatedAt":1790295608,"recencyAt":1790295608,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-08-01a0d5ef-0ba3-7552-b090-7cfbb328e278.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295608443}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":"medium","summary":"detailed","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna","reasoning_effort":"medium","developer_instructions":"You are in Plan mode. Prefer request_user_input for clarifying questions. When presenting a complete plan, wrap it in and ."}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295608464}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson index 6f4cc8790081..6c7c2e350011 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295630509}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295630509}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295630,"updatedAt":1790295630,"recencyAt":1790295630,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-30-01a0d5ef-62bd-7c20-861b-0528ff7d86bd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: provider thread resume fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: provider thread resume fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295630711}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295630,"updatedAt":1790295630,"recencyAt":1790295630,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-30-01a0d5ef-62bd-7c20-861b-0528ff7d86bd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295630711}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295630725}} @@ -41,7 +41,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295635910}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","status":{"type":"idle"}},"emittedAtMs":1790295635918}} {"type":"emit_inbound","label":"thread/resume","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: provider thread resume fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295630,"updatedAt":1790295635,"recencyAt":1790295630,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-30-01a0d5ef-62bd-7c20-861b-0528ff7d86bd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","initialTurnsPage":null,"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-62bd-7c20-861b-0528ff7d86bd\",\"rolloutOrdinal\":1,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-62bd-7c20-861b-0528ff7d86bd\",\"rolloutOrdinal\":10,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Using the conversation history available in this resumed thread, first repeat the exact final answer you gave in the previous turn. Then on a new line write exactly: provider thread resume fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Using the conversation history available in this resumed thread, first repeat the exact final answer you gave in the previous turn. Then on a new line write exactly: provider thread resume fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","turnId":"01a0d5ef-637e-7ba2-bf9a-5534a6f98a83","tokenUsage":{"total":{"totalTokens":21432,"inputTokens":21421,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":11,"reasoningOutputTokens":0},"last":{"totalTokens":21432,"inputTokens":21421,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":11,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295635930}} {"type":"emit_inbound","label":"thread/goal/cleared","frame":{"method":"thread/goal/cleared","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd"},"emittedAtMs":1790295635930}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295635949}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson index 87b22b157849..7d77017ddad7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295489278}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295489278}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295489,"updatedAt":1790295489,"recencyAt":1790295489,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-09-01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295489,"updatedAt":1790295489,"recencyAt":1790295489,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-09-01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295489585}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295489585}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295489600}} @@ -28,7 +28,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","turnId":"01a0d5ed-3c38-77a3-ab7d-7b1af28c714e","tokenUsage":{"total":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":11008,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":11008,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295494469}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","status":{"type":"idle"}},"emittedAtMs":1790295494492}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","turn":{"id":"01a0d5ed-3c38-77a3-ab7d-7b1af28c714e","items":[{"type":"agentMessage","id":"msg_0e736caa8e522655016ab5bdc6199487d097782bddce2cbb65","text":"first fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295489,"completedAt":1790295494,"durationMs":4885}},"emittedAtMs":1790295494492}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-4f65-7182-8985-cb9fba86acad","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295494545}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","turn":{"id":"01a0d5ed-4f65-7182-8985-cb9fba86acad","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295494,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295494545}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson index 0449437ce6ce..22004f5ed8d8 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson @@ -7,7 +7,7 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295403441}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295404347}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295404,"updatedAt":1790295404,"recencyAt":1790295404,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-16-44-01a0d5eb-f0c0-7481-a417-5e8871805cbc.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with the following text: fixture simple ok","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with the following text: fixture simple ok","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295404,"updatedAt":1790295404,"recencyAt":1790295404,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-16-44-01a0d5eb-f0c0-7481-a417-5e8871805cbc.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295404904}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295404904}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295404904}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson index 159cb29b193c..e2c26c6605a8 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson @@ -7,7 +7,7 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790298236268}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790298236941}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d617-2920-75e0-8957-ee5a48afefc4","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d617-2920-75e0-8957-ee5a48afefc4","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-luna","reasoningEffort":null,"createdAt":1790298237,"updatedAt":1790298237,"recencyAt":1790298237,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T18-03-57-01a0d617-2920-75e0-8957-ee5a48afefc4.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-5.6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn 2 subagents, one to read package.json and one to read tsconfig.json","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn 2 subagents, one to read package.json and one to read tsconfig.json","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790298237401}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d617-2920-75e0-8957-ee5a48afefc4","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d617-2920-75e0-8957-ee5a48afefc4","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-luna","reasoningEffort":null,"createdAt":1790298237,"updatedAt":1790298237,"recencyAt":1790298237,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T18-03-57-01a0d617-2920-75e0-8957-ee5a48afefc4.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790298237402}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"on-request","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-5.6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790298237414}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson index bc9abb86e901..359b3735d15e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790296707143}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790296707143}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-luna","reasoningEffort":null,"createdAt":1790296707,"updatedAt":1790296707,"recencyAt":1790296707,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-38-27-01a0d5ff-d059-77b3-a2cc-81be5c40d448.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-5.6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent and have it reply exactly: initial subagent response","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent and have it reply exactly: initial subagent response","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-luna","reasoningEffort":null,"createdAt":1790296707,"updatedAt":1790296707,"recencyAt":1790296707,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-38-27-01a0d5ff-d059-77b3-a2cc-81be5c40d448.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790296707328}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296707328}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-5.6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790296707337}} @@ -76,7 +76,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","turnId":"01a0d5ff-d107-72b0-b80e-eee523c86b42","tokenUsage":{"total":{"totalTokens":88196,"inputTokens":87953,"cachedInputTokens":61440,"cacheWriteInputTokens":0,"outputTokens":243,"reasoningOutputTokens":74},"last":{"totalTokens":22955,"inputTokens":22918,"cachedInputTokens":22272,"cacheWriteInputTokens":0,"outputTokens":37,"reasoningOutputTokens":27},"modelContextWindow":258400}},"emittedAtMs":1790296721553}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","status":{"type":"idle"}},"emittedAtMs":1790296721558}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","turn":{"id":"01a0d5ff-d107-72b0-b80e-eee523c86b42","items":[{"type":"agentMessage","id":"msg_0cd47e60427f33b8016ab5c29140b487d0abe357be8230ade9","text":"initial subagent response","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790296707,"completedAt":1790296721,"durationMs":14219}},"emittedAtMs":1790296721558}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Have the same subagent you spawned earlier reply exactly: continued subagent response","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Have the same subagent you spawned earlier reply exactly: continued subagent response","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d600-0899-7c10-9106-0eac1b8d18f9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790296721577}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","turn":{"id":"01a0d600-0899-7c10-9106-0eac1b8d18f9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790296721,"completedAt":null,"durationMs":null}},"emittedAtMs":1790296721577}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson index 92deff35622f..21db786c6d02 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson @@ -7,7 +7,7 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790296671810}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790296672562}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790296672,"updatedAt":1790296672,"recencyAt":1790296672,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-37-52-01a0d5ff-49e8-7741-9ca7-b12aa375019a.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790296672,"updatedAt":1790296672,"recencyAt":1790296672,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-37-52-01a0d5ff-49e8-7741-9ca7-b12aa375019a.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790296673000}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296673000}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296673000}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson index cb341efd669b..d0450d3fd0db 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson @@ -7,7 +7,7 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790368128814}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790368129787}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0da41-a28d-7271-b6f6-59123b5bb248","environments":[{"environmentId":"local","cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/tmp/codex-subagent-approval-rec/repo"]}],"extra":null,"sessionId":"01a0da41-a28d-7271-b6f6-59123b5bb248","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790368129,"updatedAt":1790368129,"recencyAt":1790368129,"status":{"type":"idle"},"path":"/home/replay-user/tmp/codex-subagent-approval-rec/codex-home/sessions/2026/09/25/rollout-2026-09-25T13-28-49-01a0da41-a28d-7271-b6f6-59123b5bb248.jsonl","cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/tmp/codex-subagent-approval-rec/repo"],"instructionSources":[],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"untrusted","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"readOnly"},"summary":"detailed","threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"untrusted","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"readOnly"},"summary":"detailed","threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0da41-a28d-7271-b6f6-59123b5bb248","environments":[{"environmentId":"local","cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/tmp/codex-subagent-approval-rec/repo"]}],"extra":null,"sessionId":"01a0da41-a28d-7271-b6f6-59123b5bb248","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790368129,"updatedAt":1790368129,"recencyAt":1790368129,"status":{"type":"idle"},"path":"/home/replay-user/tmp/codex-subagent-approval-rec/codex-home/sessions/2026/09/25/rollout-2026-09-25T13-28-49-01a0da41-a28d-7271-b6f6-59123b5bb248.jsonl","cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790368129812}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790368129813}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","approvalPolicy":"untrusted","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790368129830}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson index 10905846c95b..1a25b3942962 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790296685424}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790296685424}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790296685,"updatedAt":1790296685,"recencyAt":1790296685,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-38-05-01a0d5ff-7b98-7e50-a216-1581ca5ebd3a.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent and tell it to spawn its own subagent, which must in turn spawn one more subagent whose only task is to reply with exactly: Hello. Each agent waits for its child and replies with exactly what the child said. Wait for your subagent, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent and tell it to spawn its own subagent, which must in turn spawn one more subagent whose only task is to reply with exactly: Hello. Each agent waits for its child and replies with exactly what the child said. Wait for your subagent, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790296685,"updatedAt":1790296685,"recencyAt":1790296685,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-38-05-01a0d5ff-7b98-7e50-a216-1581ca5ebd3a.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790296685651}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296685651}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790296685666}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson index c788a352fce4..da75be64ebd7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790374254500}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790374254500}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0da9f-1b19-7301-961c-a4f5e1b31832","environments":[{"environmentId":"local","cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/.cache/codex-nested-approval-rec/repo"]}],"extra":null,"sessionId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790374255,"updatedAt":1790374255,"recencyAt":1790374255,"status":{"type":"idle"},"path":"/home/replay-user/.cache/codex-nested-approval-rec/codex-home/sessions/2026/09/25/rollout-2026-09-25T15-10-55-01a0da9f-1b19-7301-961c-a4f5e1b31832.jsonl","cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/.cache/codex-nested-approval-rec/repo"],"instructionSources":[],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"untrusted","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not run any commands yourself. Spawn one subagent and tell it not to run any commands itself but to spawn its own subagent, whose only task is to run this exact shell command: printf 'nested approval fixture' > nested-approval.txt and then reply with exactly: Written. Each agent waits for its child and replies with exactly what the child said. Wait for your subagent, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"readOnly"},"summary":"detailed","threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"untrusted","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not run any commands yourself. Spawn one subagent and tell it not to run any commands itself but to spawn its own subagent, whose only task is to run this exact shell command: printf 'nested approval fixture' > nested-approval.txt and then reply with exactly: Written. Each agent waits for its child and replies with exactly what the child said. Wait for your subagent, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"readOnly"},"summary":"detailed","threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0da9f-1b19-7301-961c-a4f5e1b31832","environments":[{"environmentId":"local","cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/.cache/codex-nested-approval-rec/repo"]}],"extra":null,"sessionId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790374255,"updatedAt":1790374255,"recencyAt":1790374255,"status":{"type":"idle"},"path":"/home/replay-user/.cache/codex-nested-approval-rec/codex-home/sessions/2026/09/25/rollout-2026-09-25T15-10-55-01a0da9f-1b19-7301-961c-a4f5e1b31832.jsonl","cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790374255463}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790374255463}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","approvalPolicy":"untrusted","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790374255475}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson index 489b4ac97ac3..dd620e327bf7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295575970}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295575970}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295576,"updatedAt":1790295576,"recencyAt":1790295576,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-19-36-01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker source-marker-7Q9V for later in this conversation. Respond with exactly: source marker stored","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker source-marker-7Q9V for later in this conversation. Respond with exactly: source marker stored","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295576205}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295576,"updatedAt":1790295576,"recencyAt":1790295576,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-19-36-01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295576205}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295576225}} @@ -31,7 +31,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295580849}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295580849}} {"type":"emit_inbound","label":"thread/fork","frame":{"id":4,"result":{"thread":{"id":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","forkedFromId":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295580,"updatedAt":1790295580,"recencyAt":1790295580,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-19-40-01a0d5ee-9fec-76a0-b5a5-b59c2a46906d.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d5ee-8e97-7201-b5b3-68c61f6b4cba","items":[{"type":"userMessage","id":"01a0d5ee-984e-7c72-9078-aa432676b1fc","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker source-marker-7Q9V for later in this conversation. Respond with exactly: source marker stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_014f95841dc5b650016ab5be1c299c87d09a10902b0219f7f3","text":"source marker stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790295576,"completedAt":1790295580,"durationMs":4383}]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the second opaque marker fork-marker-2K4M for later in this conversation. Respond with exactly: fork marker stored","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the second opaque marker fork-marker-2K4M for later in this conversation. Respond with exactly: fork marker stored","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","turnId":"01a0d5ee-8e97-7201-b5b3-68c61f6b4cba","tokenUsage":{"total":{"totalTokens":21441,"inputTokens":21434,"cachedInputTokens":14080,"cacheWriteInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"last":{"totalTokens":21441,"inputTokens":21434,"cachedInputTokens":14080,"cacheWriteInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295580880}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","forkedFromId":"01a0d5ee-8dbe-7bd3-9a3e-115bc6af5a1b","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295580,"updatedAt":1790295580,"recencyAt":1790295580,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-19-40-01a0d5ee-9fec-76a0-b5a5-b59c2a46906d.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295580880}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295580898}} @@ -52,7 +52,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","turnId":"01a0d5ee-a0d7-7c81-9908-36e557ecf0bf","tokenUsage":{"total":{"totalTokens":47268,"inputTokens":47243,"cachedInputTokens":20992,"cacheWriteInputTokens":0,"outputTokens":25,"reasoningOutputTokens":9},"last":{"totalTokens":25827,"inputTokens":25809,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":18,"reasoningOutputTokens":9},"modelContextWindow":258400}},"emittedAtMs":1790295584581}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","status":{"type":"idle"}},"emittedAtMs":1790295584596}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","turn":{"id":"01a0d5ee-a0d7-7c81-9908-36e557ecf0bf","items":[{"type":"agentMessage","id":"msg_014f95841dc5b650016ab5be2048a887d0be90298d74539689","text":"fork marker stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295580,"completedAt":1790295584,"durationMs":3695}},"emittedAtMs":1790295584598}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Return the two opaque markers previously provided in chronological order, separated by a single | character. Respond with only the markers and separator.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Return the two opaque markers previously provided in chronological order, separated by a single | character. Respond with only the markers and separator.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0d5ee-af59-7852-9ce4-03cc64145130","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295584652}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ee-9fec-76a0-b5a5-b59c2a46906d","turn":{"id":"01a0d5ee-af59-7852-9ce4-03cc64145130","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295584,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295584652}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson index 0132d24d8a80..93d16ba09578 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson @@ -5,7 +5,7 @@ {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000}}}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1777424017,"updatedAt":1777424017,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-37-019dd6ba-2681-7bf0-b051-141b0cbcbb27.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.5","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":["/home/replay-user/.codex/memories"],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"permissionProfile":{"network":{"enabled":false},"fileSystem":{"entries":[{"path":{"type":"special","value":{"kind":"root"}},"access":"read"},{"path":{"type":"special","value":{"kind":"current_working_directory"}},"access":"write"},{"path":{"type":"special","value":{"kind":"slash_tmp"}},"access":"write"},{"path":{"type":"special","value":{"kind":"tmpdir"}},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/.codex/memories"},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/Development/Work/codething-mvp/.git/worktrees/t3code-c1e5e1d1"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.git"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.codex"},"access":"read"}]}},"reasoningEffort":"medium"}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1777424017,"updatedAt":1777424017,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-37-019dd6ba-2681-7bf0-b051-141b0cbcbb27.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]}}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha"}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha"}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"`[features].collab` is deprecated. Use `[features].multi_agent` instead.","details":"Enable it with `--enable multi_agent` or `[features].multi_agent` in config.toml. See https://developers.openai.com/codex/config-basic#feature-flags for details."}}} {"type":"emit_inbound","label":"warning","frame":{"method":"warning","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","message":"Under-development features enabled: apply_patch_freeform. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in /home/replay-user/.codex/config.toml."}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"codex_apps","status":"starting","error":null}}} @@ -31,7 +31,7 @@ {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":61,"windowDurationMins":300,"resetsAt":1777429830},"secondary":{"usedPercent":23,"windowDurationMins":10080,"resetsAt":1777959590},"credits":null,"planType":"unknown","rateLimitReachedType":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","turn":{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880}}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary beta"}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary beta"}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","turn":{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[],"status":"inProgress","error":null,"startedAt":1777424023,"completedAt":null,"durationMs":null}}}} @@ -56,7 +56,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"codex_apps","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"computer-use","status":"ready","error":null}}} {"type":"emit_inbound","label":"thread/fork","frame":{"id":5,"result":{"thread":{"id":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","forkedFromId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","preview":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","ephemeral":false,"modelProvider":"openai","createdAt":1777424025,"updatedAt":1777424025,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-45-019dd6ba-47b7-7092-8688-9cf7fe5f6498.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"969e8ea4fac1f2066ed50dd4f82b5fd0014d8a4d","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"fork boundary alpha","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880}]},"model":"gpt-5.5","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":["/home/replay-user/.codex/memories"],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"permissionProfile":{"network":{"enabled":false},"fileSystem":{"entries":[{"path":{"type":"special","value":{"kind":"root"}},"access":"read"},{"path":{"type":"special","value":{"kind":"current_working_directory"}},"access":"write"},{"path":{"type":"special","value":{"kind":"slash_tmp"}},"access":"write"},{"path":{"type":"special","value":{"kind":"tmpdir"}},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/.codex/memories"},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/Development/Work/codething-mvp/.git/worktrees/t3code-c1e5e1d1"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.git"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.codex"},"access":"read"}]}},"reasoningEffort":"medium"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"Repeat the user-visible conversation so far verbatim. Include only user and assistant messages. Do not include hidden system/developer content."}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","approvalsReviewer":"user","summary":"detailed"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"Repeat the user-visible conversation so far verbatim. Include only user and assistant messages. Do not include hidden system/developer content."}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"019dd6ba-47eb-7041-ad45-5abe752c28c9","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","turn":{"id":"019dd6ba-47eb-7041-ad45-5abe752c28c9","items":[],"status":"inProgress","error":null,"startedAt":1777424025,"completedAt":null,"durationMs":null}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson index 030d2c3a0dc0..f8a329ab3cfd 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790297741094}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790297741094}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d60f-9778-7e42-9665-fcc03ff16529","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-9778-7e42-9665-fcc03ff16529","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297741,"updatedAt":1790297741,"recencyAt":1790297741,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-41-01a0d60f-9778-7e42-9665-fcc03ff16529.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker sibling-source-8R3D for later in this conversation. Respond with exactly: sibling source stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-9778-7e42-9665-fcc03ff16529"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker sibling-source-8R3D for later in this conversation. Respond with exactly: sibling source stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-9778-7e42-9665-fcc03ff16529","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d60f-9778-7e42-9665-fcc03ff16529","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-9778-7e42-9665-fcc03ff16529","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297741,"updatedAt":1790297741,"recencyAt":1790297741,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-41-01a0d60f-9778-7e42-9665-fcc03ff16529.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297741450}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-9778-7e42-9665-fcc03ff16529","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297741450}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d60f-9778-7e42-9665-fcc03ff16529","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297741465}} @@ -33,7 +33,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-ab19-73c3-a515-18e6b7f73999","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297746389}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-ab19-73c3-a515-18e6b7f73999","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297746389}} {"type":"emit_inbound","label":"thread/fork","frame":{"id":4,"result":{"thread":{"id":"01a0d60f-ab19-73c3-a515-18e6b7f73999","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-ab19-73c3-a515-18e6b7f73999","forkedFromId":"01a0d60f-9778-7e42-9665-fcc03ff16529","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297746,"updatedAt":1790297746,"recencyAt":1790297746,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-46-01a0d60f-ab19-73c3-a515-18e6b7f73999.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d60f-9892-7f93-b79e-f5b7a69cacfb","items":[{"type":"userMessage","id":"01a0d60f-a1e7-7fb3-b3f5-27a183350d89","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker sibling-source-8R3D for later in this conversation. Respond with exactly: sibling source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_0052ea29510335a5016ab5c691dae087d08079b58defc08b50","text":"sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297741,"completedAt":1790297746,"durationMs":4697}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker sibling-first-5L2P. Return the source marker followed by this marker, separated by |. Respond with only the markers and separator.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-ab19-73c3-a515-18e6b7f73999"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker sibling-first-5L2P. Return the source marker followed by this marker, separated by |. Respond with only the markers and separator.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-ab19-73c3-a515-18e6b7f73999","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d60f-ab19-73c3-a515-18e6b7f73999","turnId":"01a0d60f-9892-7f93-b79e-f5b7a69cacfb","tokenUsage":{"total":{"totalTokens":17749,"inputTokens":17741,"cachedInputTokens":11776,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":17749,"inputTokens":17741,"cachedInputTokens":11776,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297746398}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d60f-ab19-73c3-a515-18e6b7f73999","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-ab19-73c3-a515-18e6b7f73999","forkedFromId":"01a0d60f-9778-7e42-9665-fcc03ff16529","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297746,"updatedAt":1790297746,"recencyAt":1790297746,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-46-01a0d60f-ab19-73c3-a515-18e6b7f73999.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297746398}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d60f-ab19-73c3-a515-18e6b7f73999","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297746414}} @@ -71,7 +71,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297750665}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297750665}} {"type":"emit_inbound","label":"thread/fork","frame":{"id":6,"result":{"thread":{"id":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","forkedFromId":"01a0d60f-9778-7e42-9665-fcc03ff16529","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297750,"updatedAt":1790297750,"recencyAt":1790297750,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-50-01a0d60f-bc01-74c2-b3c9-eb58a6441a5f.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d60f-9892-7f93-b79e-f5b7a69cacfb","items":[{"type":"userMessage","id":"01a0d60f-a1e7-7fb3-b3f5-27a183350d89","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker sibling-source-8R3D for later in this conversation. Respond with exactly: sibling source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_0052ea29510335a5016ab5c691dae087d08079b58defc08b50","text":"sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297741,"completedAt":1790297746,"durationMs":4697}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker sibling-second-9N6C. Return the source marker followed by this marker, separated by |. Respond with only the markers and separator.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker sibling-second-9N6C. Return the source marker followed by this marker, separated by |. Respond with only the markers and separator.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","turnId":"01a0d60f-9892-7f93-b79e-f5b7a69cacfb","tokenUsage":{"total":{"totalTokens":17749,"inputTokens":17741,"cachedInputTokens":11776,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":17749,"inputTokens":17741,"cachedInputTokens":11776,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297750673}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","forkedFromId":"01a0d60f-9778-7e42-9665-fcc03ff16529","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297750,"updatedAt":1790297750,"recencyAt":1790297750,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-50-01a0d60f-bc01-74c2-b3c9-eb58a6441a5f.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297750673}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d60f-bc01-74c2-b3c9-eb58a6441a5f","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297750692}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson index c03a366e380b..0674d949f141 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790297773374}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790297773374}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d610-155f-7391-843e-120bf1475357","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-155f-7391-843e-120bf1475357","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297773,"updatedAt":1790297773,"recencyAt":1790297773,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-13-01a0d610-155f-7391-843e-120bf1475357.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d610-155f-7391-843e-120bf1475357","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-155f-7391-843e-120bf1475357","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297773,"updatedAt":1790297773,"recencyAt":1790297773,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-13-01a0d610-155f-7391-843e-120bf1475357.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297773588}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297773588}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297773608}} @@ -33,7 +33,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297778313}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297778313}} {"type":"emit_inbound","label":"thread/fork","frame":{"id":4,"result":{"thread":{"id":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297778,"updatedAt":1790297778,"recencyAt":1790297778,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-18-01a0d610-2789-7bb3-a492-ad7fa8a7db9c.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[{"type":"userMessage","id":"01a0d610-1f48-7281-871b-c5d12ab0a228","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b1b0fc87d0a540ea4be4112eb0","text":"merge sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297773,"completedAt":1790297778,"durationMs":4400}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-sibling-first-6V2J. Respond with exactly: first merge sibling stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-sibling-first-6V2J. Respond with exactly: first merge sibling stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","turnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","tokenUsage":{"total":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297778338}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297778,"updatedAt":1790297778,"recencyAt":1790297778,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-18-01a0d610-2789-7bb3-a492-ad7fa8a7db9c.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297778338}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297778364}} @@ -57,7 +57,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297782559}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297782559}} {"type":"emit_inbound","label":"thread/fork","frame":{"id":6,"result":{"thread":{"id":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297782,"updatedAt":1790297782,"recencyAt":1790297782,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-22-01a0d610-37d1-7ba1-918f-136cfdbd9bfd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[{"type":"userMessage","id":"01a0d610-1f48-7281-871b-c5d12ab0a228","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b1b0fc87d0a540ea4be4112eb0","text":"merge sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297773,"completedAt":1790297778,"durationMs":4400}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-sibling-second-9X5B. Respond with exactly: second merge sibling stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-sibling-second-9X5B. Respond with exactly: second merge sibling stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","turnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","tokenUsage":{"total":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297782630}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297782,"updatedAt":1790297782,"recencyAt":1790297782,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-22-01a0d610-37d1-7ba1-918f-136cfdbd9bfd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297782630}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297782690}} @@ -77,7 +77,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","turnId":"01a0d610-398b-7ea0-b252-1ba58fc7d14d","tokenUsage":{"total":{"totalTokens":39723,"inputTokens":39707,"cachedInputTokens":32384,"cacheWriteInputTokens":0,"outputTokens":16,"reasoningOutputTokens":0},"last":{"totalTokens":21971,"inputTokens":21963,"cachedInputTokens":17536,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297786356}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","status":{"type":"idle"}},"emittedAtMs":1790297786381}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","turn":{"id":"01a0d610-398b-7ea0-b252-1ba58fc7d14d","items":[{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b9e23887d0aead55548c08d807","text":"second merge sibling stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297782,"completedAt":1790297786,"durationMs":3683}},"emittedAtMs":1790297786381}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from first forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-first-6V2J.\n- Assistant confirmed: first merge sibling stored\n\nUser message:\nRetain the first transferred marker for later. Respond with exactly: first merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from first forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-first-6V2J.\n- Assistant confirmed: first merge sibling stored\n\nUser message:\nRetain the first transferred marker for later. Respond with exactly: first merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":8,"result":{"turn":{"id":"01a0d610-481e-7522-ab04-234cac3907b5","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297786444}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-481e-7522-ab04-234cac3907b5","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297786,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297786444}} @@ -97,7 +97,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turnId":"01a0d610-481e-7522-ab04-234cac3907b5","tokenUsage":{"total":{"totalTokens":39782,"inputTokens":39751,"cachedInputTokens":32384,"cacheWriteInputTokens":0,"outputTokens":31,"reasoningOutputTokens":13},"last":{"totalTokens":22030,"inputTokens":22007,"cachedInputTokens":17536,"cacheWriteInputTokens":0,"outputTokens":23,"reasoningOutputTokens":13},"modelContextWindow":258400}},"emittedAtMs":1790297790394}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"idle"}},"emittedAtMs":1790297790409}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-481e-7522-ab04-234cac3907b5","items":[{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6be15bc87d0b5c0c3a9a6045bbb","text":"first merge delta stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297786,"completedAt":1790297790,"durationMs":3979}},"emittedAtMs":1790297790409}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":9,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from second forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-second-9X5B.\n- Assistant confirmed: second merge sibling stored\n\nUser message:\nRetain the second transferred marker for later. Respond with exactly: second merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":9,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from second forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-second-9X5B.\n- Assistant confirmed: second merge sibling stored\n\nUser message:\nRetain the second transferred marker for later. Respond with exactly: second merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":9,"result":{"turn":{"id":"01a0d610-57d2-7671-858a-d9e868574664","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297790438}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-57d2-7671-858a-d9e868574664","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297790,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297790438}} @@ -112,7 +112,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turnId":"01a0d610-57d2-7671-858a-d9e868574664","tokenUsage":{"total":{"totalTokens":61893,"inputTokens":61854,"cachedInputTokens":54272,"cacheWriteInputTokens":0,"outputTokens":39,"reasoningOutputTokens":13},"last":{"totalTokens":22111,"inputTokens":22103,"cachedInputTokens":21888,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297793240}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"idle"}},"emittedAtMs":1790297793249}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-57d2-7671-858a-d9e868574664","items":[{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6c071ec87d0ad6a38add1f18e1a","text":"second merge delta stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297790,"completedAt":1790297793,"durationMs":2814}},"emittedAtMs":1790297793249}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":10,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Return the source marker followed by both transferred fork markers in merge order, separated by single | characters. Respond with only the markers and separators.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":10,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Return the source marker followed by both transferred fork markers in merge order, separated by single | characters. Respond with only the markers and separators.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":10,"result":{"turn":{"id":"01a0d610-62e7-7610-9b44-1d654367e4bf","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297793308}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-62e7-7610-9b44-1d654367e4bf","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297793,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297793308}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson index 82d2b2d2e26c..92cf454144cd 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295644807}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295644807}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295644,"updatedAt":1790295644,"recencyAt":1790295644,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-44-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295644,"updatedAt":1790295644,"recencyAt":1790295644,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-44-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295645009}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295645009}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295645023}} @@ -28,7 +28,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turnId":"01a0d5ef-9b58-7980-9e7a-2463dd068a8e","tokenUsage":{"total":{"totalTokens":21428,"inputTokens":21419,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"last":{"totalTokens":21428,"inputTokens":21419,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295649339}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"idle"}},"emittedAtMs":1790295649352}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turn":{"id":"01a0d5ef-9b58-7980-9e7a-2463dd068a8e","items":[{"type":"agentMessage","id":"msg_044082e5a0068297016ab5be60f20c87d0aed9858a5d1f00b2","text":"rollback fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295645,"completedAt":1790295649,"durationMs":4326}},"emittedAtMs":1790295649352}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295649424}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turn":{"id":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295649,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295649424}} @@ -55,7 +55,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295651544}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"idle"}},"emittedAtMs":1790295651551}} {"type":"emit_inbound","label":"thread/revert","frame":{"id":7,"result":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295651,"updatedAt":1790295651,"recencyAt":1790295649,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-51-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff_01a0d5ef-b424-72f2-9853-9b112f028a71.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]},"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":1,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":10,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/reverted","frame":{"method":"thread/reverted","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"},"emittedAtMs":1790295651568}} {"type":"emit_inbound","label":"turn/start","frame":{"id":8,"result":{"turn":{"id":"01a0d5ef-b4f4-7aa2-b4c2-f5f494881401","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295651594}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson index 2e5d54b8ddbc..73140365895f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790404926353}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790404926353}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790404926,"updatedAt":1790404926,"recencyAt":1790404926,"status":{"type":"idle"},"path":"/home/replay-user/tmp/bughunt-codex/codex-home/sessions/2026/09/25/rollout-2026-09-25T23-42-06-01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"],"instructionSources":["/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790404926,"updatedAt":1790404926,"recencyAt":1790404926,"status":{"type":"idle"},"path":"/home/replay-user/tmp/bughunt-codex/codex-home/sessions/2026/09/25/rollout-2026-09-25T23-42-06-01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790404926549}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790404926550}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790404926569}} @@ -28,7 +28,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turnId":"01a0dc73-1c60-70a3-9a3c-d54c0020efa2","tokenUsage":{"total":{"totalTokens":19805,"inputTokens":19796,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"last":{"totalTokens":19805,"inputTokens":19796,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790404930976}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"idle"}},"emittedAtMs":1790404931000}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-1c60-70a3-9a3c-d54c0020efa2","items":[{"type":"agentMessage","id":"msg_0f3ce1c685f16088016ab7694298d087d0a53f8de6070d33ad","text":"rollback fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790404926,"completedAt":1790404930,"durationMs":4426}},"emittedAtMs":1790404931000}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790404931059}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790404931,"completedAt":null,"durationMs":null}},"emittedAtMs":1790404931059}} @@ -69,7 +69,7 @@ {"type":"expect_outbound","label":"thread/resume","frame":{"id":6,"method":"thread/resume","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000},"cwd":"","excludeTurns":true,"model":"gpt-6-luna","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"}}} {"type":"emit_inbound","label":"thread/reverted","frame":{"method":"thread/reverted","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"},"emittedAtMs":1790404935694}} {"type":"emit_inbound","label":"thread/resume","frame":{"id":6,"result":{"thread":{"id":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790404935,"updatedAt":1790404935,"recencyAt":1790404931,"status":{"type":"idle"},"path":"/home/replay-user/tmp/bughunt-codex/codex-home/sessions/2026/09/25/rollout-2026-09-25T23-42-15-01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a_01a0dc73-3f89-7660-9a2a-81865e6273eb.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"70629cdddbe2c29749e8dce588b1828580b2777b","branch":"fix/codex-rollback-resume","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":"default","disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"],"instructionSources":["/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","initialTurnsPage":null,"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a\",\"rolloutOrdinal\":1,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a\",\"rolloutOrdinal\":10,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/goal/cleared","frame":{"method":"thread/goal/cleared","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"},"emittedAtMs":1790404935729}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":"default","effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790404935753}} {"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"01a0dc73-4034-7201-88e4-13d0d27303a3","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson index 9a84e2d64e52..0a8249db9349 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790459891821}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790459891821}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459892,"updatedAt":1790459892,"recencyAt":1790459892,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-12-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"],"instructionSources":[],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459892,"updatedAt":1790459892,"recencyAt":1790459892,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-12-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790459892845}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790459892846}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790459892862}} @@ -26,7 +26,7 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-d479-70c0-9d88-912b52f7f613","tokenUsage":{"total":{"totalTokens":15291,"inputTokens":15282,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"last":{"totalTokens":15291,"inputTokens":15282,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790459897074}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"idle"}},"emittedAtMs":1790459897082}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-d479-70c0-9d88-912b52f7f613","items":[{"type":"agentMessage","id":"msg_0508263b0487774d016ab83ff8973c87d0afab5ac113234803","text":"rollback fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790459892,"completedAt":1790459897,"durationMs":4216}},"emittedAtMs":1790459897082}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0dfb9-e509-7001-ba72-fe324f527fb9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790459897121}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-e509-7001-ba72-fe324f527fb9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790459897,"completedAt":null,"durationMs":null}},"emittedAtMs":1790459897121}} @@ -61,7 +61,7 @@ {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"idle"}},"emittedAtMs":1790459903899}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-e509-7001-ba72-fe324f527fb9","items":[],"itemsView":"notLoaded","status":"interrupted","error":null,"startedAt":1790459897,"completedAt":1790459903,"durationMs":6789}},"emittedAtMs":1790459903899}} {"type":"emit_inbound","label":"thread/backgroundTerminals/terminate","frame":{"id":6,"result":{"terminated":true}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790459903907}} {"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"commandExecution","id":"exec-6964412e-1d91-47b5-8a37-7a669b30c03e","pluginId":null,"scriptPath":null,"command":"/bin/bash -lc \"node -e \\\"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\\\"\"","cwd":"/home/replay-user/t3code","processId":"26675","source":"unifiedExecStartup","status":"failed","commandActions":[{"type":"unknown","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\""}],"aggregatedOutput":null,"exitCode":-1,"durationMs":22},"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9","completedAtMs":1790459903901},"emittedAtMs":1790459903912}} {"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} @@ -89,7 +89,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790459906471}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"idle"}},"emittedAtMs":1790459906478}} {"type":"emit_inbound","label":"thread/revert","frame":{"id":10,"result":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459906,"updatedAt":1790459906,"recencyAt":1790459903,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-26-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88_01a0dfba-094c-77d1-8e0b-8bf690a73635.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"147b1509eac4291b323371299d0925a44035680c","branch":"master","originUrl":null},"name":null,"daybreakEnabled":null,"turns":[]},"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":16,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":30,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":11,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":11,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/reverted","frame":{"method":"thread/reverted","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"},"emittedAtMs":1790459906490}} {"type":"emit_inbound","label":"turn/start","frame":{"id":11,"result":{"turn":{"id":"01a0dfba-09be-7112-8c42-4a6a8d1fe1d6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790459906518}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson index 45ff652ca872..60c9f5f26087 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson @@ -7,7 +7,7 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790306244669}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790306245652}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790306245,"updatedAt":1790306245,"recencyAt":1790306245,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T20-17-25-01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Use the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Use the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790306245,"updatedAt":1790306245,"recencyAt":1790306245,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T20-17-25-01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790306245772}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790306245772}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790306245772}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson index 222f903ec6a5..7dc172b19710 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790296989543}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790296989543}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d604-1f8d-7522-8fe1-371c6626896e","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d604-1f8d-7522-8fe1-371c6626896e","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790296989,"updatedAt":1790296989,"recencyAt":1790296989,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-43-09-01a0d604-1f8d-7522-8fe1-371c6626896e.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296989839}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d604-1f8d-7522-8fe1-371c6626896e","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d604-1f8d-7522-8fe1-371c6626896e","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790296989,"updatedAt":1790296989,"recencyAt":1790296989,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-43-09-01a0d604-1f8d-7522-8fe1-371c6626896e.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790296989839}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"on-request","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790296989848}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson index 9e62c615a307..5a422a670f88 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790297006729}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790297006729}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d604-629d-7162-b667-18c96f6fdf56","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d604-629d-7162-b667-18c96f6fdf56","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-terra","reasoningEffort":null,"createdAt":1790297006,"updatedAt":1790297006,"recencyAt":1790297006,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-43-26-01a0d604-629d-7162-b667-18c96f6fdf56.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-5.6-terra","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":{"granular":{"mcp_elicitations":true,"request_permissions":true,"rules":true,"sandbox_approval":true,"skill_approval":true}},"approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-5.6-terra","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d604-629d-7162-b667-18c96f6fdf56"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":{"granular":{"mcp_elicitations":true,"request_permissions":true,"rules":true,"sandbox_approval":true,"skill_approval":true}},"approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-5.6-terra","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-terra"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d604-629d-7162-b667-18c96f6fdf56","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d604-629d-7162-b667-18c96f6fdf56","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-terra","reasoningEffort":null,"createdAt":1790297006,"updatedAt":1790297006,"recencyAt":1790297006,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-43-26-01a0d604-629d-7162-b667-18c96f6fdf56.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297006923}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297006923}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":{"granular":{"sandbox_approval":true,"rules":true,"skill_approval":true,"request_permissions":true,"mcp_elicitations":true}},"approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-5.6-terra","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-terra","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297006943}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson index 154cddf85310..6a1a4644798c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295509345}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295509345}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295509,"updatedAt":1790295509,"recencyAt":1790295509,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-29-01a0d5ed-896f-73b3-8c42-3ffa820eb343.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295509,"updatedAt":1790295509,"recencyAt":1790295509,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-29-01a0d5ed-896f-73b3-8c42-3ffa820eb343.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295509540}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295509540}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295509553}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson index c26fd92f1f5d..e1aef6032f3c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295488496}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295488496}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-3804-72d0-9a3f-929885a11f32","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295488,"updatedAt":1790295488,"recencyAt":1790295488,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-08-01a0d5ed-3804-72d0-9a3f-929885a11f32.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-3804-72d0-9a3f-929885a11f32","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295488,"updatedAt":1790295488,"recencyAt":1790295488,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-08-01a0d5ed-3804-72d0-9a3f-929885a11f32.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295488683}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295488683}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295488699}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson index f7665df581cc..0d315b0d729e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790297700256}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790297700256}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790297700,"updatedAt":1790297700,"recencyAt":1790297700,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-00-01a0d60e-f7cc-7a50-bdff-90174e2c3b56.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790297700,"updatedAt":1790297700,"recencyAt":1790297700,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-00-01a0d60e-f7cc-7a50-bdff-90174e2c3b56.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297700615}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297700615}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297700629}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson index 04ce406a64fc..9405ecc1909f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295574462}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295574462}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ee-87e4-7461-9ab7-5c316b503107","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295574,"updatedAt":1790295574,"recencyAt":1790295574,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-19-34-01a0d5ee-87e4-7461-9ab7-5c316b503107.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Search the web for FIFA World Cup ticket pricing, then answer exactly: web search fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Search the web for FIFA World Cup ticket pricing, then answer exactly: web search fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ee-87e4-7461-9ab7-5c316b503107","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295574,"updatedAt":1790295574,"recencyAt":1790295574,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-19-34-01a0d5ee-87e4-7461-9ab7-5c316b503107.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295574652}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295574652}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295574669}} diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index d3067e51caaf..24e38d21b4a3 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -30,7 +30,7 @@ describe("buildCodexAdditionalContext", () => { }); }); -describe("buildCodexDeveloperInstructions", () => { +describe("Codex runtime context", () => { it("flattens multiline metadata into single-line runtime info", () => { const instructions = runtimeInstructions({ model: "gpt\n5.3\ncodex", diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index a3ff122bcd22..a42766e49aa2 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,4 +1,3 @@ -import type { ProviderInteractionMode } from "@t3tools/contracts"; import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; @@ -35,161 +34,12 @@ const toolInstructions = (availability: boolean | T3CodeToolAvailability): strin .join("\n\n"); }; -const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) - -You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. - -## Mode rules (strict) - -You are in **Plan Mode** until a developer message explicitly ends it. - -Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. - -## Plan Mode vs update_plan tool - -Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a \`\` block. - -Separately, \`update_plan\` is a checklist/progress/TODOs tool; it does not enter or exit Plan Mode. Do not confuse it with Plan mode or try to use it while in Plan mode. If you try to use \`update_plan\` in Plan mode, it will return an error. - -## Execution vs. mutation in Plan Mode - -You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. - -### Allowed (non-mutating, plan-improving) - -Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: - -* Reading or searching files, configs, schemas, types, manifests, and docs -* Static analysis, inspection, and repo exploration -* Dry-run style commands when they do not edit repo-tracked files -* Tests, builds, or checks that may write to caches or build artifacts (for example, \`target/\`, \`.cache/\`, or snapshots) so long as they do not edit repo-tracked files - -### Not allowed (mutating, plan-executing) - -Actions that implement the plan or change repo-tracked state. Examples: - -* Editing or writing files -* Running formatters or linters that rewrite files -* Applying patches, migrations, or codegen that updates repo-tracked files -* Side-effectful commands whose purpose is to carry out the plan rather than refine it - -When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. - -## PHASE 1 - Ground in the environment (explore first, ask second) - -Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. - -Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. - -Exception: you may ask clarifying questions about the user's prompt before exploring, ONLY if there are obvious ambiguities or contradictions in the prompt itself. However, if ambiguity might be resolved by exploring, always prefer exploring first. - -Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. - -## PHASE 2 - Intent chat (what they actually want) - -* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. -* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet-ask. - -## PHASE 3 - Implementation chat (what/how we'll build) - -* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. - -## Asking questions - -Critical rules: - -* Strongly prefer using the \`request_user_input\` tool to ask any questions. -* Offer only meaningful multiple-choice options; don't include filler choices that are obviously wrong or irrelevant. -* In rare cases where an unavoidable, important question can't be expressed with reasonable multiple-choice options (due to extreme ambiguity), you may ask it directly without the tool. - -You SHOULD ask many questions, but each question must: - -* materially change the spec/plan, OR -* confirm/lock an assumption, OR -* choose between meaningful tradeoffs. -* not be answerable by non-mutating commands. - -Use the \`request_user_input\` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. - -## Two kinds of unknowns (treat differently) - -1. **Discoverable facts** (repo/system truth): explore first. - - * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). - * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. - * If asking, present concrete candidates (paths/service names) + recommend one. - * Never ask questions you can answer from your environment (e.g., "where is this struct"). - -2. **Preferences/tradeoffs** (not discoverable): ask early. - - * These are intent or implementation preferences that cannot be derived from exploration. - * Provide 2-4 mutually exclusive options + a recommended default. - * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. - -## Finalization rule - -Only output the final plan when it is decision complete and leaves no decisions to the implementer. - -When you present the official plan, wrap it in a \`\` block so the client can render it specially: - -1) The opening tag must be on its own line. -2) Start the plan content on the next line (no text on the same line as the tag). -3) The closing tag must be on its own line. -4) Use Markdown inside the block. -5) Keep the tags exactly as \`\` and \`\` (do not translate or rename them), even if the plan content is in another language. - -Example: - - -plan content - - -plan content should be human and agent digestible. The final plan must be plan-only, concise by default, and include: - -* A clear title -* A brief summary section -* Important changes or additions to public APIs/interfaces/types -* Test cases and scenarios -* Explicit assumptions and defaults chosen where needed - -When possible, prefer a compact structure with 3-5 short sections, usually: Summary, Key Changes or Implementation Changes, Test Plan, and Assumptions. Do not include a separate Scope section unless scope boundaries are genuinely important to avoid mistakes. - -Prefer grouped implementation bullets by subsystem or behavior over file-by-file inventories. Mention files only when needed to disambiguate a non-obvious change, and avoid naming more than 3 paths unless extra specificity is necessary to prevent mistakes. Prefer behavior-level descriptions over symbol-by-symbol removal lists. For v1 feature-addition plans, do not invent detailed schema, validation, precedence, fallback, or wire-shape policy unless the request establishes it or it is needed to prevent a concrete implementation mistake; prefer the intended capability and minimum interface/behavior changes. - -Keep bullets short and avoid explanatory sub-bullets unless they are needed to prevent ambiguity. Prefer the minimum detail needed for implementation safety, not exhaustive coverage. Within each section, compress related changes into a few high-signal bullets and omit branch-by-branch logic, repeated invariants, and long lists of unaffected behavior unless they are necessary to prevent a likely implementation mistake. Avoid repeated repo facts and irrelevant edge-case or rollout detail. For straightforward refactors, keep the plan to a compact summary, key edits, tests, and assumptions. If the user asks for more detail, then expand. - -Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. - -Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. - -If the user stays in Plan mode and asks for revisions after a prior \`\`, any new \`\` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a \`\` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior \`\` unchanged. -`; - -const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default - -You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. - -Your active mode changes only when new developer instructions with a different \`...\` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan. - -## request_user_input availability - -Use the \`request_user_input\` tool only when it is listed in the available tools for this turn. - -In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. -`; - export interface CodexRuntimeInfo { readonly model: string; readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; } -export function buildCodexDeveloperInstructions(interactionMode: ProviderInteractionMode): string { - return interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; -} - /** Keep this outside collaboration mode; model-catalog mode text overrides client developer_instructions. */ export function buildCodexAdditionalContext( runtime: CodexRuntimeInfo, From 242d00cd34534e7a93cb6f6b02cf4652bdd1210e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 23:57:17 -0700 Subject: [PATCH 12/34] fix(clients): threads no longer flash in the inbox before they settle (#24760) With the Working shelf on, a thread that stops working stays there for 2s. A server auto-settle in that window sends it straight to Settled, so threads no longer flash in the inbox and vanish. "Thread completed" waits the same 2s and fires once. The server now settles fast enough to land inside the window: it reads a pull request when a shell merge or close command ends, sweeps a thread when its background work ends, and runs per-thread settle checks in parallel, one per thread at a time. Takes over #22458. Co-authored-by: Omar <76748259+omar9848492@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../src/features/threads/threadListV2.test.ts | 31 ++- .../src/features/threads/threadListV2.ts | 3 +- .../src/features/threads/useThreadListV2.ts | 16 +- .../PullRequestSyncReactor.test.ts | 90 ++++++- .../PullRequestSyncReactor.ts | 30 ++- .../ThreadSettlementService.test.ts | 159 +++++++++++ .../ThreadSettlementService.ts | 71 ++++- apps/web/src/components/Sidebar.tsx | 29 ++- ...ficationCoordinator.badge.browser.test.tsx | 28 +- ...hreadNotificationCoordinator.logic.test.ts | 193 ++++++++++++++ .../ThreadNotificationCoordinator.logic.ts | 78 ++++++ .../ThreadNotificationCoordinator.tsx | 246 ++++++++++-------- .../src/state/threadInbox.test.ts | 188 ++++++++++++- .../client-runtime/src/state/threadInbox.ts | 162 ++++++++++-- 14 files changed, 1137 insertions(+), 187 deletions(-) create mode 100644 apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts create mode 100644 apps/web/src/components/ThreadNotificationCoordinator.logic.ts diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 58d06bd5e696..cb23296d41c8 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,4 +1,5 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; import { presentThreadShell } from "@t3tools/client-runtime/state/models"; import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; @@ -24,7 +25,7 @@ import { RunId, ThreadId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures"; @@ -1562,6 +1563,34 @@ describe("Working section beta", () => { expect(off.workingCount).toBe(0); }); + it("keeps an inactive row in Working during the shared grace and returns it on expiry", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + const tracker = createInboxReturnTracker(); + const working = makeThread({ id: ThreadId.make("grace"), title: "grace", runtime: running }); + const inactive = { ...working, runtime: null }; + const render = () => + build({ + threads: [inactive], + workingShelfExpanded: true, + isWorking: tracker.isWorking, + inboxReturnAt: tracker.returnedAt, + }); + try { + tracker.observe([working]); + tracker.observe([inactive]); + expect(render().workingCount).toBe(1); + expect(render().items[0]?.thread.runtime).toBeNull(); + vi.advanceTimersByTime(2_000); + expect(render().workingCount).toBe(0); + expect(ids(render())).toEqual(["grace"]); + expect(tracker.returnedAt(inactive)).toBe(Date.parse(NOW) + 2_000); + } finally { + tracker.observe(null); + vi.useRealTimers(); + } + }); + it("orders the inbox by the latest return this device observed", () => { const layout = build({ inboxReturnAt: (thread) => diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2486d33fee89..424bd792c40d 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -577,6 +577,7 @@ export function buildThreadListV2Items(input: { readonly now: string; readonly workingShelfEnabled?: boolean; readonly workingShelfExpanded?: boolean; + readonly isWorking?: (thread: EnvironmentThreadShell) => boolean; readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined; readonly snoozedShelfExpanded?: boolean; readonly settledShelfExpanded?: boolean; @@ -659,7 +660,7 @@ export function buildThreadListV2Items(input: { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); - } else if (workingShelfEnabled && isThreadWorking(thread)) { + } else if (workingShelfEnabled && (input.isWorking ?? isThreadWorking)(thread)) { working.push(thread); } else { active.push(thread); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 12d861d193a7..2a2ab54033ea 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,5 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { useMemo } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -54,10 +54,15 @@ export function useThreadListV2(input: { readonly selectedThreadKey: string | null; }) { const { threads, workingShelfEnabled } = input; - // Observed once per change to the threads: that is when a thread can stop working. - const inboxReturnAt = useMemo( + // A thread can stop working when the threads change, or when its exit grace expires. + const inboxVersion = useSyncExternalStore( + threadListInboxReturns.subscribe, + threadListInboxReturns.getSnapshot, + ); + const inbox = useMemo( () => threadListInboxReturns.observe(workingShelfEnabled ? threads : null), - [threads, workingShelfEnabled], + // oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace + [threads, workingShelfEnabled, inboxVersion], ); // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made @@ -97,7 +102,8 @@ export function useThreadListV2(input: { now: input.now, workingShelfEnabled, workingShelfExpanded: input.workingShelfExpanded, - inboxReturnAt, + inboxReturnAt: inbox.returnedAt, + isWorking: inbox.isWorking, snoozedShelfExpanded: input.snoozedShelfExpanded, settledShelfExpanded: input.settledShelfExpanded, selectedThreadKey: input.selectedThreadKey, diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 184a0295b653..b978f211589b 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1340,7 +1340,7 @@ describe("PullRequestSyncReactor", () => { "gh pr merge 7 --squash 2>&1 | tail -3", "cafe pr merge 7 --squash", "az repos pr update --id 7 --status completed", - ])("reads open links fresh only when a run that ran `%s` ends", (mergeCommand) => + ])("reads open links fresh only for a thread that ran `%s`", (mergeCommand) => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -1392,4 +1392,92 @@ describe("PullRequestSyncReactor", () => { }), ), ); + + it.effect("reads open links again when the run ends, after `merge --auto` left them open", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Queue.offer( + fixture.domainEvents, + commandRan(ThreadId.make("agent"), "gh pr merge 7 --auto"), + ); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.syncCommands, []); + + yield* Queue.offer(fixture.domainEvents, runUpdated(ThreadId.make("agent"), "completed")); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("reads open links fresh as soon as a merge command ends, before its run ends", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.summaryCalls, []); + + yield* Queue.offer( + fixture.domainEvents, + commandRan(ThreadId.make("agent"), "gh pr merge 7"), + ); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index e2be84c5663d..fb9fa25c6e1b 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -1,5 +1,6 @@ import { CommandId, + isOrchestrationV2WorkActive, type PullRequestSummary, type ThreadId, type ThreadPullRequestKey, @@ -481,7 +482,8 @@ export const make = Effect.gen(function* () { }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), ); - const closeCommandThreads = new Set(); + // Threads that ran a close command this run, with the commands already read for. + const closeCommands = new Map>(); const refreshOpenLinks = (threadId: ThreadId) => projections.getThreadsWithPullRequests(threadId).pipe( Effect.flatMap((threads) => @@ -525,20 +527,22 @@ export const make = Effect.gen(function* () { { discard: true }, ); // An agent can merge or close its pull request from a shell with its host's CLI, which - // sends no merge notification. When a run that ran such a command ends, read the - // thread's open links fresh, so settlement does not wait for the next sweep and the - // cached summary. Other runs add no host reads. - case "turn-item.updated": - if ( - event.payload.type === "command_execution" && - isChangeRequestCloseCommand(event.payload.input) - ) { - closeCommandThreads.add(event.threadId); + // sends no merge notification. Read the thread's open links fresh once that command + // ends, while the agent usually still replies, so settlement can land before the run + // ends. Read again when the run ends, since `merge --auto` returns before it merges. + case "turn-item.updated": { + const item = event.payload; + if (item.type !== "command_execution" || !isChangeRequestCloseCommand(item.input)) { + return Effect.void; } - return Effect.void; + const ended = closeCommands.get(event.threadId) ?? new Set(); + closeCommands.set(event.threadId, ended); + if (isOrchestrationV2WorkActive(item.status) || ended.has(item.id)) return Effect.void; + ended.add(item.id); + return refreshOpenLinks(event.threadId); + } case "run.updated": - return isTerminalRunStatus(event.payload.status) && - closeCommandThreads.delete(event.threadId) + return isTerminalRunStatus(event.payload.status) && closeCommands.delete(event.threadId) ? refreshOpenLinks(event.threadId) : Effect.void; default: diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index c16a012945c5..50ac83b81462 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -6,6 +6,7 @@ import { ProjectId, EventId, ProviderInstanceId, + RunId, ThreadId, type OrchestrationProjectShell, type OrchestrationV2AppThread, @@ -892,6 +893,164 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("settles a merged thread when its background work ends, during a slow full sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const periodicLookupStarted = yield* Deferred.make(); + const releasePeriodicLookup = yield* Deferred.make(); + const settled = yield* Deferred.make(); + const branchLookupCount = yield* Ref.make(0); + const mergedLink: NonNullable[number] = { + ...persistedLink("open", DateTime.makeUnsafe("2026-08-20T00:00:00.000Z")), + snapshot: { + state: "merged", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: NOW, + }, + }; + const waiting = makeThread("waiting-on-subagent", { + pullRequests: [mergedLink], + pendingBackgroundTasks: [{ taskId: "child", kind: "subagent", description: "Review" }], + }); + const slow = makeThread("slow-periodic-lookup", { + branch: "another-feature", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([waiting, slow]), + branchPullRequest: () => + Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.succeed(makeBranchPullRequest("open")) + : Deferred.succeed(periodicLookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePeriodicLookup)), + Effect.as(makeBranchPullRequest("open")), + ), + ), + ), + onDispatch: () => Deferred.succeed(settled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Deferred.await(periodicLookupStarted); + + yield* Ref.set( + fixture.snapshots, + makeSnapshot([{ ...waiting, pendingBackgroundTasks: [] }, slow]), + ); + yield* fixture.publishEvent({ + type: "run.background-work-cancelled", + id: EventId.make("event:background-ended"), + threadId: waiting.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: { runId: RunId.make("run:waiting"), restartCancelledBackgroundWork: [] }, + }); + yield* Deferred.await(settled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [waiting.id], + ); + yield* Deferred.succeed(releasePeriodicLookup, undefined); + yield* reactor.drain; + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("settles a stopped thread while another thread's lookup is slow", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const slowLookupStarted = yield* Deferred.make(); + const releaseSlowLookup = yield* Deferred.make(); + const settled = yield* Deferred.make(); + const branchLookupCount = yield* Ref.make(0); + const recent = DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"); + const slow = makeThread("slow-lookup", { branch: "feature", latestUserMessageAt: recent }); + const merged = makeThread("merged", { + latestUserMessageAt: recent, + pullRequests: [ + { + ...persistedLink("open", recent), + snapshot: { + state: "merged", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: NOW, + }, + }, + ], + pendingBackgroundTasks: [{ taskId: "child", kind: "subagent", description: "Review" }], + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([slow, merged]), + branchPullRequest: () => + Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.succeed(makeBranchPullRequest("open")) + : Deferred.succeed(slowLookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSlowLookup)), + Effect.as(makeBranchPullRequest("open")), + ), + ), + ), + onDispatch: () => Deferred.succeed(settled, undefined), + }); + const backgroundEnded = (threadId: ThreadId): OrchestrationV2DomainEvent => ({ + type: "run.background-work-cancelled", + id: EventId.make(`event:background-ended:${threadId}`), + threadId, + occurredAt: DateTime.makeUnsafe(NOW), + payload: { runId: RunId.make(`run:${threadId}`), restartCancelledBackgroundWork: [] }, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + yield* fixture.publishEvent(backgroundEnded(slow.id)); + yield* Deferred.await(slowLookupStarted); + // More events for the slow thread coalesce into one more pass, not more sweeps. + for (let index = 0; index < 9; index++) { + yield* fixture.publishEvent(backgroundEnded(slow.id)); + } + + yield* Ref.set( + fixture.snapshots, + makeSnapshot([slow, { ...merged, pendingBackgroundTasks: [] }]), + ); + yield* fixture.publishEvent(backgroundEnded(merged.id)); + yield* Deferred.await(settled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [merged.id], + ); + yield* Deferred.succeed(releaseSlowLookup, undefined); + yield* reactor.drain; + // The startup sweep, the first per-thread sweep, and one coalesced rerun. + assert.strictEqual(yield* Ref.get(branchLookupCount), 3); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect( "settles branch threads on a pull request merge without waiting for the next sweep", () => diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index bd5b7d346fb1..a14601ae8405 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -1,8 +1,12 @@ -import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + backgroundWorkHoldsCompletion, + turnItemUpdateCanEndBackgroundWork, +} from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { CommandId, + isOrchestrationV2WorkActive, type ThreadId, type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, @@ -13,9 +17,11 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FiberSet from "effect/FiberSet"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -523,9 +529,35 @@ export const make = Effect.gen(function* () { }), ), ); - const worker = yield* makeDrainableWorker((threadId: ThreadId | undefined) => - runSweep(null, threadId), - ); + // A thread that just stopped must not wait behind a full sweep or another thread's host + // reads: clients hold it in Working only briefly, so a late settle makes it appear and then vanish. + const fullSweeps = yield* makeDrainableWorker(() => runSweep(null)); + const threadSweeps = yield* FiberSet.make(); + const threadSweepSlots = yield* Semaphore.make(8); + // One sweep per thread at a time; events during a running sweep coalesce into one more pass. + const threadSweepStates = new Map(); + const sweepThread = ( + threadId: ThreadId, + ): Effect.Effect>> => + Effect.sync(() => threadSweepStates.set(threadId, "running")).pipe( + Effect.andThen(runSweep(null, threadId)), + threadSweepSlots.withPermits(1), + Effect.andThen( + Effect.suspend(() => { + if (threadSweepStates.get(threadId) === "rerun") return sweepThread(threadId); + threadSweepStates.delete(threadId); + return Effect.void; + }), + ), + ); + const enqueueThread = (threadId: ThreadId) => + Effect.suspend(() => { + const state = threadSweepStates.get(threadId); + if (state === "running") threadSweepStates.set(threadId, "rerun"); + if (state !== undefined) return Effect.void; + threadSweepStates.set(threadId, "queued"); + return sweepThread(threadId).pipe(FiberSet.run(threadSweeps), Effect.asVoid); + }); // Skip settle scripts in shared checkouts where other threads may still work. const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")( @@ -572,16 +604,30 @@ export const make = Effect.gen(function* () { case "thread.pull-request-link-updated": case "thread.pull-request-synced": case "provider-session.detached": - return worker.enqueue(event.threadId); + case "run.background-work-cancelled": + return enqueueThread(event.threadId); + // Sweep only when the work can have ended: progress updates cannot settle anything. + case "subagent.updated": + return isOrchestrationV2WorkActive(event.payload.status) + ? Effect.void + : enqueueThread(event.threadId); + case "provider-thread.updated": + return backgroundWorkHoldsCompletion(event.payload.pendingBackgroundTasks ?? []) + ? Effect.void + : enqueueThread(event.threadId); + case "turn-item.updated": + return turnItemUpdateCanEndBackgroundWork(event.payload) + ? enqueueThread(event.threadId) + : Effect.void; case "provider-session.updated": return event.payload.status !== "starting" && event.payload.status !== "running" - ? worker.enqueue(event.threadId) + ? enqueueThread(event.threadId) : Effect.void; case "run.updated": return ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( event.payload.status, ) - ? worker.enqueue(event.threadId) + ? enqueueThread(event.threadId) : Effect.void; default: return Effect.void; @@ -598,8 +644,8 @@ export const make = Effect.gen(function* () { let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); yield* forkParked( Effect.gen(function* () { - yield* worker.enqueue(undefined); - yield* worker.drain; + yield* fullSweeps.enqueue(undefined); + yield* fullSweeps.drain; }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), ); yield* forkParked( @@ -609,7 +655,7 @@ export const make = Effect.gen(function* () { return Effect.void; } lastSettlementSettings = key; - return worker.enqueue(undefined); + return fullSweeps.enqueue(undefined); }), ); yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event))); @@ -622,7 +668,10 @@ export const make = Effect.gen(function* () { ); }); - return { start, drain: worker.drain } satisfies ThreadSettlementServiceV2["Service"]; + return { + start, + drain: Effect.andThen(fullSweeps.drain, FiberSet.awaitEmpty(threadSweeps)), + } satisfies ThreadSettlementServiceV2["Service"]; }); export const layer = Layer.effect(ThreadSettlementServiceV2, make); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8d40d5f01a48..2101d2ddae55 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,7 +28,10 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; +import { + createInboxReturnTracker, + isThreadWorking, +} from "@t3tools/client-runtime/state/thread-inbox"; import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { nextShownOrder, @@ -61,6 +64,7 @@ import { useReducer, useRef, useState, + useSyncExternalStore, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, @@ -154,7 +158,6 @@ import { filterSidebarProjectScopeItems, firstValidTimestampMs, isSidebarNestedLinkClick, - isSidebarThreadWorking, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, @@ -302,6 +305,9 @@ interface SidebarThreadPartitionInput { readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly workingShelfEnabled: boolean; + // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend + // on the clock, so the minute tick, the precise snooze-wake tick and the inbox + // tracker's expiry version are inputs: a new key repartitions. readonly clockKey: string; } @@ -375,6 +381,10 @@ function useSidebarThreads(input: { ), ); const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); + const inboxReturnsVersion = useSyncExternalStore( + inboxReturns.subscribe, + inboxReturns.getSnapshot, + ); const nowMinute = useNowMinute(); // Minute-quantized clocks would delay second-precise snooze wakes by up to a minute. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); @@ -414,7 +424,7 @@ function useSidebarThreads(input: { serverConfigs, optimisticDrop, workingShelfEnabled, - clockKey: `${nowMinute}:${snoozeWakeTick}`, + clockKey: `${nowMinute}:${snoozeWakeTick}:${inboxReturnsVersion}`, }); const partitionNextWakeAt = partition.snoozedThreads[0]?.snoozedUntil ?? null; if (partitionNextWakeAt !== nextSnoozeWakeAt) setNextSnoozeWakeAt(partitionNextWakeAt); @@ -506,12 +516,15 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const { threads, scopedProjectKeys, serverConfigs, optimisticDrop, workingShelfEnabled } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); - inboxReturns.observe(workingShelfEnabled ? threads : null); + const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; - const inbox = (thread: EnvironmentThreadShell) => - workingShelfEnabled && isSidebarThreadWorking(thread) ? working : active; + const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => + workingShelfEnabled && + (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) + ? working + : active; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const draggable = new Set(); @@ -536,7 +549,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { ? pinned : optimisticDrop.section === "settled" ? settled - : inbox(projected) + : inbox(projected, true) ).push( optimisticDrop.clearsSnooze ? projected @@ -561,7 +574,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { // Capability gates dragging only; mixed server versions must preserve the same pinned order across clients. const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = workingShelfEnabled - ? sortInboxThreadsByReturn(active, inboxReturns.returnedAt) + ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) : sortThreadsForSidebar(active); return { pinnedThreads: diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index 145d6f740f02..fe0e21190d02 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -1,11 +1,12 @@ import { RegistryContext } from "@effect/atom-react"; +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; import { EnvironmentId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; -import { render, stubGlobal, type Screen } from "../../test/render"; +import { fakeTimers, render, stubGlobal, type Screen } from "../../test/render"; const state = vi.hoisted(() => ({ mode: "notifications", @@ -235,6 +236,31 @@ it.each([ latestTurn: { turnId: "turn", state: status, completedAt: "2026-09-13T07:01:00.000Z" }, }), ); + using _timers = fakeTimers(); await screen.rerender(app.ui()); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); expect(TestNotification.sent.map((notification) => notification.title)).toEqual(titles); }); + +it("holds a completion through a continuation gap and notifies once it settles", async () => { + await using app = setup(); + await app.render(); + using _timers = fakeTimers(); + const finished = (turnId: string, completedAt: string) => ({ + latestTurn: { turnId, state: "completed", completedAt }, + }); + state.shells.set("one", shell(finished("turn-1", "2026-09-13T07:00:01.000Z"))); + await app.render(); + state.shells.set( + "one", + shell({ latestTurn: { turnId: "turn-2", state: "running", completedAt: null } }), + ); + await app.render(); + state.shells.set("one", shell(finished("turn-2", "2026-09-13T07:00:02.000Z"))); + await app.render(); + expect(TestNotification.sent).toEqual([]); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS - 1); + expect(TestNotification.sent).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts new file mode 100644 index 000000000000..2bba09f54dcb --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -0,0 +1,193 @@ +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createThreadNotificationGate, + type ThreadNotificationObservation, +} from "./ThreadNotificationCoordinator.logic"; + +const working: ThreadNotificationObservation = { + attention: null, + settled: false, + completedAt: null, +}; +const completed = (at: number): ThreadNotificationObservation => ({ + attention: null, + settled: true, + completedAt: at, +}); +const visible = { archived: false, quiet: false }; + +function harness() { + const notify = vi.fn(); + const gate = createThreadNotificationGate(notify); + let prior: ThreadNotificationObservation | undefined; + const observe = (next: ThreadNotificationObservation, options = visible) => { + gate.observe("thread", prior, next, options); + prior = next; + }; + return { gate, notify, observe }; +} + +describe("createThreadNotificationGate", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("drops a completion when the thread resumes within the grace", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS - 1); + observe(working); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).not.toHaveBeenCalled(); + }); + + it("notifies a real completion once after the grace", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS - 1); + expect(notify).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(notify.mock.calls).toEqual([["thread", "completion"]]); + observe({ ...completed(1) }); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("notifies attention at once and replaces a pending completion", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe({ attention: "run-1:approval", settled: false, completedAt: null }); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + vi.advanceTimersByTime(60_000); + // The request resolves and the same run reads as completed again. + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + }); + + it("notifies one completion across continuation gaps", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(300); + observe(working); + vi.advanceTimersByTime(5_000); + observe(completed(2)); + vi.advanceTimersByTime(300); + observe(working); + vi.advanceTimersByTime(5_000); + observe(completed(3)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + expect(notify.mock.calls).toEqual([["thread", "completion"]]); + }); + + it("keeps a completion interrupted by attention after a working gap from firing", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe(working); + observe({ attention: "run-1:approval", settled: false, completedAt: null }); + vi.advanceTimersByTime(60_000); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + }); + + it("adds nothing when attention follows a completion that already fired", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + observe({ attention: "run-1:limited", settled: false, completedAt: null }); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify.mock.calls).toEqual([ + ["thread", "completion"], + ["thread", "input"], + ]); + }); + + it("still notifies a newer completion after attention", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe({ attention: "run-2:approval", settled: false, completedAt: null }); + observe(working); + observe(completed(2)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + expect(notify.mock.calls).toEqual([ + ["thread", "input"], + ["thread", "completion"], + ]); + }); + + it("ignores unrelated updates while a completion waits", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(1_500); + observe({ ...completed(1) }); + vi.advanceTimersByTime(500); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("notifies a run that settles again after a brief runtime blip", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + expect(notify.mock.calls).toEqual([["thread", "completion"]]); + }); + + it("restarts the grace when a newer completion replaces a pending one", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(1_500); + observe(completed(2)); + vi.advanceTimersByTime(1_500); + expect(notify).not.toHaveBeenCalled(); + vi.advanceTimersByTime(500); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("drops a pending completion on archive, stop request, removal or clear", () => { + for (const end of [ + (h: ReturnType) => h.observe(completed(1), { ...visible, archived: true }), + (h: ReturnType) => + h.observe({ ...completed(1) }, { ...visible, quiet: true }), + (h: ReturnType) => h.gate.retain(new Set()), + (h: ReturnType) => h.gate.clear(), + ]) { + const h = harness(); + h.observe(working); + h.observe(completed(1)); + end(h); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(h.notify).not.toHaveBeenCalled(); + } + }); + + it("never notifies a stopped run or the first observation", () => { + const { notify, observe } = harness(); + observe(completed(1)); + observe(working); + observe(completed(2), { ...visible, quiet: true }); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts new file mode 100644 index 000000000000..77edccfbe774 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -0,0 +1,78 @@ +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; + +export type ThreadNotificationKind = "input" | "completion"; + +export interface ThreadNotificationObservation { + /** Run and status key while the thread needs attention, otherwise null. */ + readonly attention: string | null; + /** The thread is idle and its latest run completed. */ + readonly settled: boolean; + /** When the latest run completed, if it did. */ + readonly completedAt: number | null; +} + +/** + * Attention notifies at once. Completion waits out the Working shelf's exit grace, + * because an orchestrator run can end moments before its continuation starts. Any + * observation that is not settled during the wait cancels it. + */ +export function createThreadNotificationGate( + notify: (key: Key, kind: ThreadNotificationKind) => void, +) { + const pending = new Map }>(); + // The newest completion already notified, or deliberately skipped. + const handled = new Map(); + // The newest completion seen settled, kept while the thread works or waits. + const lastSettled = new Map(); + const cancel = (key: Key) => { + clearTimeout(pending.get(key)?.timer); + pending.delete(key); + }; + const skip = (key: Key, at: number | null) => { + if (at !== null && at > (handled.get(key) ?? -Infinity)) handled.set(key, at); + }; + return { + observe( + key: Key, + prior: ThreadNotificationObservation | undefined, + next: ThreadNotificationObservation, + options: { readonly archived: boolean; readonly quiet: boolean }, + ): void { + if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt); + if (!next.settled || options.archived || options.quiet) cancel(key); + // The first sighting, archived threads, and stopped or silent runs never notify completion. + if (!prior || options.archived || options.quiet) skip(key, next.completedAt); + if (!prior || options.archived) return; + if (next.attention && next.attention !== prior.attention) { + // Attention replaces the completion before it, so that run must not notify later. + skip(key, lastSettled.get(key) ?? null); + cancel(key); + notify(key, "input"); + return; + } + const at = next.completedAt; + if (!next.settled || at === null || at <= (handled.get(key) ?? -Infinity)) return; + if (pending.get(key)?.at === at) return; + cancel(key); + pending.set(key, { + at, + timer: setTimeout(() => { + pending.delete(key); + handled.set(key, at); + notify(key, "completion"); + }, WORKING_EXIT_GRACE_MS), + }); + }, + /** Forgets threads that left the snapshot. */ + retain(keys: ReadonlySet): void { + for (const key of pending.keys()) if (!keys.has(key)) cancel(key); + for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key); + for (const key of lastSettled.keys()) if (!keys.has(key)) lastSettled.delete(key); + }, + clear(): void { + for (const key of pending.keys()) cancel(key); + handled.clear(); + lastSettled.clear(); + }, + }; +} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 9769fbce75e6..1d6f37ed351d 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -1,4 +1,5 @@ import { + type EnvironmentThreadShell, type PendingSubagentRequest, presentThreadShell, } from "@t3tools/client-runtime/state/models"; @@ -13,7 +14,7 @@ import { MessageCircleQuestionIcon, ShieldQuestionIcon, } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { useEffect, useEffectEvent, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useEnvironmentIds } from "../state/environments"; @@ -26,7 +27,12 @@ import { threadNotificationTag, unlockNotificationAudio, } from "../threadNotifications"; -import { resolveSidebarThreadStatus } from "./Sidebar.logic"; +import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic"; +import { + createThreadNotificationGate, + type ThreadNotificationKind, + type ThreadNotificationObservation, +} from "./ThreadNotificationCoordinator.logic"; import { toastManager } from "./ui/toast"; export function ThreadNotificationCoordinator() { @@ -93,11 +99,11 @@ export function ThreadNotificationCoordinator() { )); } -interface NotificationState { +interface NotificationState extends ThreadNotificationObservation { readonly raw: OrchestrationV2ThreadShell; readonly subagentRequest: PendingSubagentRequest | null; - readonly attention: string | null; - readonly completion: number | null; + readonly thread: EnvironmentThreadShell; + readonly status: SidebarThreadStatus; } function EnvironmentNotifications({ @@ -120,13 +126,116 @@ function EnvironmentNotifications({ }); const previous = useRef(new Map()); + // Completions fire from a timer, so read settings, focus and route when presenting. + const present = useEffectEvent((threadId: ThreadId, kind: ThreadNotificationKind) => { + const entry = previous.current.get(threadId); + if (!entry) return; + const { thread, status } = entry; + const title = + kind === "completion" + ? "Thread completed" + : status === "approval" + ? "Approval needed" + : status === "limited" + ? "Usage limit reached" + : status === "failed" + ? "Thread failed" + : status === "request" + ? "Guest request" + : "Input needed"; + if (hasNotificationSound(mode)) { + void playNotificationSound(kind, () => + hasNotificationSound(getClientSettings().notificationMode), + ); + } + if ( + inAppNotificationsEnabled && + document.visibilityState === "visible" && + document.hasFocus() && + (activeEnvironmentId !== environmentId || activeThreadId !== thread.id) + ) { + const toastId = toastManager.add({ + type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", + title, + description: thread.title, + data: { + hideCopyButton: true, + leadingIcon: + kind === "completion" ? ( + + ) : status === "approval" ? ( + + ) : status === "failed" ? ( + + ) : ( + + ), + }, + actionProps: { + children: "Open thread", + onClick: () => { + toastManager.close(toastId); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }, + }, + }); + return; + } + if ( + !hasDesktopNotifications(mode) || + (document.visibilityState === "visible" && document.hasFocus()) || + typeof Notification === "undefined" || + Notification.permission !== "granted" + ) + return; + try { + const notification = new Notification(title, { + body: thread.title, + tag: threadNotificationTag(environmentId, thread.id), + silent: true, + }); + onNotification(environmentId, notification); + notification.addEventListener("click", () => { + notification.close(); + window.focus(); + void window.desktopBridge?.revealWindow?.().catch(() => undefined); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }); + } catch { + // Some browsers expose Notification but reject desktop presentation. + } + }); + const gate = useRef>>(null); useEffect(() => { + const created = createThreadNotificationGate((threadId, kind) => + present(threadId, kind), + ); + gate.current = created; + return () => { + created.clear(); + gate.current = null; + // A new gate has no pending completions, so forget the states they compared against. + previous.current.clear(); + }; + }, []); + + useEffect(() => { + const pendingCompletions = gate.current; + if (!pendingCompletions) return; if (threads === null) { + pendingCompletions.clear(); previous.current.clear(); return; } const next = new Map(); const subagentRequests = pendingSubagentRequestsByThread(threads); + const changed: Array<[NotificationState | undefined, NotificationState]> = []; for (const rawThread of threads) { if (rawThread.lineage.relationshipToParent === "subagent") continue; const prior = previous.current.get(rawThread.id); @@ -147,115 +256,28 @@ function EnvironmentNotifications({ ? `${thread.latestRun?.runId ?? ""}:${status}` : null; const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); - const completion = - status === "ready" && - thread.latestRun?.status === "completed" && - Number.isFinite(completedAt) - ? completedAt - : (prior?.completion ?? null); - next.set(thread.id, { raw: rawThread, subagentRequest, attention, completion }); - if (!prior || thread.archivedAt !== null) continue; - const kind = - attention && attention !== prior.attention - ? "input" - : !rawThread.latestRunStopRequested && - !rawThread.latestRunSilent && - completion !== null && - (prior.completion === null || completion > prior.completion) - ? "completion" - : null; - if (!kind) continue; - const title = - kind === "completion" - ? "Thread completed" - : status === "approval" - ? "Approval needed" - : status === "limited" - ? "Usage limit reached" - : status === "failed" - ? "Thread failed" - : status === "request" - ? "Guest request" - : "Input needed"; - if (hasNotificationSound(mode)) { - void playNotificationSound(kind, () => - hasNotificationSound(getClientSettings().notificationMode), - ); - } - if ( - inAppNotificationsEnabled && - document.visibilityState === "visible" && - document.hasFocus() && - (activeEnvironmentId !== environmentId || activeThreadId !== thread.id) - ) { - const toastId = toastManager.add({ - type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", - title, - description: thread.title, - data: { - hideCopyButton: true, - leadingIcon: - kind === "completion" ? ( - - ) : status === "approval" ? ( - - ) : status === "failed" ? ( - - ) : ( - - ), - }, - actionProps: { - children: "Open thread", - onClick: () => { - toastManager.close(toastId); - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId: thread.id }, - }); - }, - }, - }); - continue; - } - if ( - !hasDesktopNotifications(mode) || - (document.visibilityState === "visible" && document.hasFocus()) || - typeof Notification === "undefined" || - Notification.permission !== "granted" - ) - continue; - try { - const notification = new Notification(title, { - body: thread.title, - tag: threadNotificationTag(environmentId, thread.id), - silent: true, - }); - onNotification(environmentId, notification); - notification.addEventListener("click", () => { - notification.close(); - window.focus(); - void window.desktopBridge?.revealWindow?.().catch(() => undefined); - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId: thread.id }, - }); - }); - } catch { - // Some browsers expose Notification but reject desktop presentation. - } + const settled = status === "ready" && thread.latestRun?.status === "completed"; + const state = { + raw: rawThread, + subagentRequest, + thread, + status, + attention, + settled, + completedAt: settled && Number.isFinite(completedAt) ? completedAt : null, + }; + next.set(thread.id, state); + changed.push([prior, state]); } previous.current = next; - }, [ - activeEnvironmentId, - activeThreadId, - environmentId, - inAppNotificationsEnabled, - mode, - navigate, - onNotification, - threads, - ]); + pendingCompletions.retain(new Set(next.keys())); + for (const [prior, state] of changed) { + pendingCompletions.observe(state.thread.id, prior, state, { + archived: state.thread.archivedAt !== null, + quiet: state.raw.latestRunStopRequested === true || state.raw.latestRunSilent === true, + }); + } + }, [environmentId, threads]); return null; } diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index f5675d3a72ee..35c7fa8355e3 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -1,7 +1,12 @@ import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { createInboxReturnTracker, sortWorkingThreadsBySend } from "./threadInbox.ts"; +import { + createInboxReturnTracker, + isThreadWorking, + sortInboxThreadsByReturn, + sortWorkingThreadsBySend, +} from "./threadInbox.ts"; const environmentId = EnvironmentId.make("environment-1"); @@ -30,18 +35,185 @@ function thread(id: string, working: boolean) { } describe("createInboxReturnTracker", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + }); + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); it("stamps a thread when it stops working, but never on the first observation", () => { const tracker = createInboxReturnTracker(); const first = tracker.observe([thread("a", true), thread("b", false)]); - expect(first(thread("a", true))).toBeUndefined(); - expect(first(thread("b", false))).toBeUndefined(); + expect(first.returnedAt(thread("a", true))).toBeUndefined(); + expect(first.returnedAt(thread("b", false))).toBeUndefined(); + const held = tracker.observe([thread("a", false), thread("b", false)]); + vi.advanceTimersByTime(2_000); const second = tracker.observe([thread("a", false), thread("b", false)]); - expect(second(thread("a", false))).toBeDefined(); - expect(second(thread("b", false))).toBeUndefined(); - expect(tracker.returnedAt(thread("a", false))).toBe(second(thread("a", false))); + expect(second.returnedAt(thread("a", false))).toBeDefined(); + expect(second.returnedAt(thread("b", false))).toBeUndefined(); + expect(tracker.returnedAt(thread("a", false))).toBe(second.returnedAt(thread("a", false))); // An earlier observation keeps its answer, so lists built from it stay valid. - expect(first(thread("a", false))).toBeUndefined(); + expect(held.isWorking(thread("a", false))).toBe(true); + expect(held.returnedAt(thread("a", false))).toBeUndefined(); + }); + + it("holds only placement, then expires and notifies without another shell event", () => { + const tracker = createInboxReturnTracker(); + const notify = vi.fn(); + const unsubscribe = tracker.subscribe(notify); + tracker.observe([thread("a", true)]); + tracker.observe([thread("a", false)]); + expect(isThreadWorking(thread("a", false))).toBe(false); + expect(tracker.isWorking(thread("a", false))).toBe(true); + vi.advanceTimersByTime(1_999); + tracker.observe([thread("a", false)]); + expect(tracker.isWorking(thread("a", false))).toBe(true); + expect(tracker.returnedAt(thread("a", false))).toBeUndefined(); + vi.advanceTimersByTime(1); + expect(tracker.isWorking(thread("a", false))).toBe(false); + expect(tracker.returnedAt(thread("a", false))).toBe(12_000); + expect(notify).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it("cancels a transient return on resume and grants a fresh grace after the next finish", () => { + const tracker = createInboxReturnTracker(); + tracker.observe([thread("a", false)]); + expect(tracker.isWorking(thread("a", false))).toBe(false); + tracker.observe([thread("a", true)]); + expect(tracker.isWorking(thread("a", true))).toBe(true); + tracker.observe([thread("a", false)]); + vi.advanceTimersByTime(1_500); + tracker.observe([thread("a", true)]); + vi.advanceTimersByTime(1_000); + expect(tracker.returnedAt(thread("a", true))).toBeUndefined(); + tracker.observe([thread("a", false)]); + vi.advanceTimersByTime(1_999); + expect(tracker.isWorking(thread("a", false))).toBe(true); + vi.advanceTimersByTime(1); + expect(tracker.returnedAt(thread("a", false))).toBe(14_500); + }); + + it.each([ + { hasPendingApprovals: true }, + { hasPendingUserInput: true }, + { pendingSubagentRequest: "approval" as const }, + { pendingSubagentRequest: "user_input" as const }, + { pendingGuestMessageCount: 1 }, + { interactionMode: "plan" as const, hasActionableProposedPlan: true }, + { runtime: { ...thread("a", true).runtime!, status: "failed" as const, lastError: "failed" } }, + { pinnedAt: "2026-06-01T00:00:01Z" }, + { settledOverride: "settled" as const }, + { snoozedUntil: "2026-06-02T00:00:00Z" }, + { activeOrderKey: "manual-order" }, + ])("ends a grace immediately for attention or manual placement: %j", (change) => { + const tracker = createInboxReturnTracker(); + tracker.observe([thread("a", true)]); + tracker.observe([thread("a", false)]); + tracker.observe([{ ...thread("a", false), ...change }]); + expect(tracker.isWorking(thread("a", false))).toBe(false); + expect(tracker.returnedAt(thread("a", false))).toBe(10_000); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["interrupted", "cancelled", "rolled_back"] as const)( + "holds a %s run, because the server can still settle it", + (status) => { + const tracker = createInboxReturnTracker(); + const stopped = { + ...thread("a", false), + latestRun: { + runId: RunId.make("stopped"), + status, + requestedAt: null, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + }; + tracker.observe([thread("a", true)]); + tracker.observe([stopped]); + expect(tracker.isWorking(stopped)).toBe(true); + tracker.observe([{ ...stopped, settledOverride: "settled" as const }]); + expect(tracker.isWorking(stopped)).toBe(false); + }, + ); + + it("grants grace after a retained expired snooze", () => { + const tracker = createInboxReturnTracker(); + const retained = { + snoozedAt: "1970-01-01T00:00:01.000Z", + snoozedUntil: "1970-01-01T00:00:05.000Z", + latestRun: { + runId: RunId.make("completed-wake"), + status: "completed" as const, + requestedAt: "1970-01-01T00:00:02.000Z", + startedAt: "1970-01-01T00:00:02.000Z", + completedAt: "1970-01-01T00:00:09.000Z", + assistantMessageId: null, + }, + }; + const active = { ...thread("a", true), ...retained }; + const finished = { ...thread("a", false), ...retained }; + tracker.observe([active]); + tracker.observe([finished]); + expect(tracker.isWorking(finished)).toBe(true); + expect(tracker.returnedAt(finished)).toBeUndefined(); + vi.advanceTimersByTime(1_999); + expect(tracker.isWorking(finished)).toBe(true); + vi.advanceTimersByTime(1); + expect(tracker.isWorking(finished)).toBe(false); + expect(tracker.returnedAt(finished)).toBe(12_000); + }); + + it("does not grant grace to a future snooze after background completion", () => { + const tracker = createInboxReturnTracker(); + const retained = { + snoozedAt: "1970-01-01T00:00:01.000Z", + snoozedUntil: "1970-01-01T00:00:30.000Z", + }; + tracker.observe([{ ...thread("a", true), ...retained }]); + const finished = { ...thread("a", false), ...retained }; + tracker.observe([finished]); + expect(tracker.isWorking(finished)).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stamps only stable returns and sorts them in their actual return order", () => { + const tracker = createInboxReturnTracker(); + const a = { ...thread("a", false), createdAt: "1970-01-01T00:00:00Z" }; + const b = { ...thread("b", false), createdAt: "1970-01-01T00:00:00Z" }; + tracker.observe([thread("a", true), thread("b", true)]); + tracker.observe([a, thread("b", true)]); + vi.advanceTimersByTime(500); + tracker.observe([a, b]); + vi.advanceTimersByTime(1_500); + expect(tracker.returnedAt(a)).toBe(12_000); + expect(tracker.returnedAt(b)).toBeUndefined(); + vi.advanceTimersByTime(500); + expect(sortInboxThreadsByReturn([a, b], tracker.returnedAt).map((entry) => entry.id)).toEqual([ + "b", + "a", + ]); + }); + + it("cancels pending timers for removed threads and disabled shelves", () => { + const tracker = createInboxReturnTracker(); + const notify = vi.fn(); + tracker.subscribe(notify); + tracker.observe([thread("a", true)]); + tracker.observe([thread("a", false)]); + tracker.observe([]); + expect(vi.getTimerCount()).toBe(0); + tracker.observe([thread("a", true)]); + tracker.observe([thread("a", false)]); + tracker.observe(null); + vi.advanceTimersByTime(2_000); + expect(notify).not.toHaveBeenCalled(); + expect(tracker.returnedAt(thread("a", false))).toBeUndefined(); }); }); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 8083dd045385..de9cd822398d 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -1,5 +1,7 @@ +// @effect-diagnostics globalTimers:off - Shelf placement timers run in web and native clients, outside an Effect runtime. import { threadRuntimeIsActive, type EnvironmentThreadShell } from "./models.ts"; import { toSortableTimestamp } from "./threadSort.ts"; +import { effectiveSnoozed } from "./threadSettled.ts"; type WorkingThreadInput = Pick< EnvironmentThreadShell, @@ -86,46 +88,154 @@ function sortNewestFirst + >; + +type ThreadKeyInput = Pick; + +/** + * How long a thread that stopped working keeps reading as working. An orchestrator + * run can end moments before its continuation starts, so clients wait this long + * before treating the thread as done. + */ +export const WORKING_EXIT_GRACE_MS = 2_000; + /** - * Keep one at module scope so the inbox order survives routes that unmount the - * list. Call `observe` with every thread shell whenever the shells change, or - * with null to reset while the beta is off. It returns the return times as of - * that observation, which stay fixed so a list built from them can be cached. + * Keep one at module scope so placement survives routes that unmount the list. Call + * `observe` when the shells or the snapshot version change, or with null while the beta + * is off. Its result stays fixed, so a list built from it can be cached. */ export function createInboxReturnTracker() { - const keyOf = (thread: Pick) => - `${thread.environmentId}:${thread.id}`; - let lastWorkingKeys: ReadonlySet | null = null; + const keyOf = (thread: ThreadKeyInput) => `${thread.environmentId}:${thread.id}`; + const states = new Map< + string, + { working: boolean; deadline: number | null; placement: string } + >(); const returns = new Map(); + const listeners = new Set<() => void>(); + let timer: ReturnType | undefined; + let version = 0; + const now = () => { + // @effect-diagnostics-next-line globalDate:off - The grace deadline is wall-clock time shared by web and native clients. + return Date.now(); + }; + const schedule = () => { + clearTimeout(timer); + timer = undefined; + let next = Infinity; + for (const state of states.values()) { + if (state.deadline !== null) next = Math.min(next, state.deadline); + } + if (next === Infinity) return; + timer = setTimeout( + () => { + const at = now(); + for (const [key, state] of states) { + if (state.deadline !== null && state.deadline <= at) { + state.working = false; + state.deadline = null; + returns.set(key, at); + } + } + schedule(); + version++; + for (const listener of listeners) listener(); + }, + Math.max(0, next - now()), + ); + }; return { - observe( - threads: ReadonlyArray | null, - ): (thread: Pick) => number | undefined { + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => version, + observe(threads: readonly TrackedInboxThread[] | null): { + readonly isWorking: (thread: WorkingThreadInput & ThreadKeyInput) => boolean; + readonly returnedAt: (thread: ThreadKeyInput) => number | undefined; + } { if (threads === null) { - lastWorkingKeys = null; + clearTimeout(timer); + timer = undefined; + states.clear(); returns.clear(); - return () => undefined; + return { isWorking: isThreadWorking, returnedAt: () => undefined }; } - const working = new Set(); const present = new Set(); + const at = now(); + // @effect-diagnostics-next-line globalDate:off - Snooze checks compare wall-clock ISO times. + const observedAt = new Date(at).toISOString(); for (const thread of threads) { const key = keyOf(thread); present.add(key); - if (isThreadWorking(thread)) working.add(key); - } - for (const key of returns.keys()) { - if (!present.has(key)) returns.delete(key); + const placement = JSON.stringify([ + thread.archivedAt, + thread.deletedAt, + thread.pinnedAt, + thread.settledOverride, + thread.settledAt, + thread.snoozedAt, + thread.snoozedUntil, + thread.activeOrderKey, + ]); + const previous = states.get(key); + const actionable = + thread.hasPendingApprovals || + thread.hasPendingUserInput || + thread.pendingSubagentRequest != null || + (thread.pendingGuestMessageCount ?? 0) > 0 || + (thread.interactionMode === "plan" && thread.hasActionableProposedPlan) || + thread.runtime?.lastError != null || + thread.latestRun?.status === "failed"; + const moved = previous !== undefined && previous.placement !== placement; + const excluded = + thread.archivedAt != null || + thread.deletedAt != null || + thread.pinnedAt != null || + thread.settledOverride === "settled" || + effectiveSnoozed(thread, { now: observedAt }); + const actual = isThreadWorking(thread); + // Only shelf placement is held: execution state and attention requests stay authoritative. + const deadline = + !actual && previous?.working && !actionable && !moved && !excluded + ? (previous.deadline ?? at + WORKING_EXIT_GRACE_MS) + : null; + const working = actual || (deadline !== null && deadline > at); + if (previous?.working && !working) returns.set(key, at); + states.set(key, { working, deadline: working ? deadline : null, placement }); } - // @effect-diagnostics-next-line globalDate:off - const at = Date.now(); - for (const key of lastWorkingKeys ?? []) { - if (present.has(key) && !working.has(key)) returns.set(key, at); + for (const key of states.keys()) { + if (!present.has(key)) { + states.delete(key); + returns.delete(key); + } } - lastWorkingKeys = working; - const observed = new Map(returns); - return (thread) => observed.get(keyOf(thread)); + schedule(); + const observedWorking = new Map([...states].map(([key, state]) => [key, state.working])); + const observedReturns = new Map(returns); + return { + isWorking: (thread) => observedWorking.get(keyOf(thread)) ?? isThreadWorking(thread), + returnedAt: (thread) => observedReturns.get(keyOf(thread)), + }; }, - returnedAt: (thread: Pick) => - returns.get(keyOf(thread)), + isWorking: (thread: WorkingThreadInput & ThreadKeyInput) => + states.get(keyOf(thread))?.working ?? isThreadWorking(thread), + returnedAt: (thread: ThreadKeyInput) => returns.get(keyOf(thread)), }; } From 906d1e9fa1509effe4f6ac5144fe2669465fa416 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:03:18 -0700 Subject: [PATCH 13/34] fix(mobile): the iPhone compose icon matches the tab icons' size (#24650) Co-authored-by: Claude Opus 5.5 --- .../modules/t3-native-controls/ios/T3SidebarTabBarView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift index b7afac0b4b98..f96c00962682 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -92,8 +92,7 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { } configuration.cornerStyle = .capsule configuration.baseForegroundColor = .label - configuration.image = UIImage(systemName: "square.and.pencil") - configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) + configuration.image = tabIcon(UIImage(systemName: "square.and.pencil")) composeButton.configuration = configuration } From a43b0032cf3cee8e16e407c7056f180fb1652a8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:06:32 -0700 Subject: [PATCH 14/34] feat(infra): share Vite task cache with Axiom savings telemetry (#24693) --- .github/actions/setup-ci-deps/action.yml | 22 + .github/workflows/ci.yml | 30 ++ .github/workflows/release.yml | 10 + .task-cache.json | 3 + apps/server/vite.config.ts | 4 +- infra/task-cache/README.md | 77 +++ infra/task-cache/alchemy.run.ts | 28 + infra/task-cache/install.sh | 3 + .../migrations/savings/0001_savings.sql | 16 + .../savings/0002_unspecified_tier.sql | 2 + .../savings/0003_remove_setup_probes.sql | 4 + infra/task-cache/package.json | 25 + infra/task-cache/scripts/install-ci.ts | 49 ++ infra/task-cache/scripts/install.ts | 307 +++++++++++ infra/task-cache/scripts/manage.ts | 72 +++ infra/task-cache/scripts/smoke.ts | 130 +++++ infra/task-cache/src/Admission.test.ts | 49 ++ infra/task-cache/src/Admission.ts | 28 + infra/task-cache/src/Cache.test.ts | 324 ++++++++++++ infra/task-cache/src/Cache.ts | 240 +++++++++ infra/task-cache/src/Savings.test.ts | 71 +++ infra/task-cache/src/Savings.ts | 140 +++++ infra/task-cache/src/Summary.ts | 64 +++ infra/task-cache/src/WriterRegistry.test.ts | 247 +++++++++ infra/task-cache/src/WriterRegistry.ts | 128 +++++ infra/task-cache/src/deploymentConfig.ts | 39 ++ infra/task-cache/src/developerAuth.test.ts | 37 ++ infra/task-cache/src/developerAuth.ts | 35 ++ infra/task-cache/src/flush.ts | 3 + infra/task-cache/src/nativeHook.js | 17 + infra/task-cache/src/nativeHook.test.ts | 86 +++ infra/task-cache/src/nativeRunHook.js | 65 +++ infra/task-cache/src/observability.ts | 203 +++++++ infra/task-cache/src/preload.mjs | 42 ++ infra/task-cache/src/protocol.ts | 21 + infra/task-cache/src/protocolTypes.ts | 14 + infra/task-cache/src/remoteCache.ts | 30 ++ infra/task-cache/src/resources.ts | 46 ++ infra/task-cache/src/runHook.test.ts | 496 ++++++++++++++++++ infra/task-cache/src/runHook.ts | 41 ++ infra/task-cache/src/telemetry.test.ts | 162 ++++++ infra/task-cache/src/telemetry.ts | 256 +++++++++ infra/task-cache/src/telemetryAuth.test.ts | 84 +++ infra/task-cache/src/telemetryAuth.ts | 44 ++ infra/task-cache/src/worker.ts | 98 ++++ infra/task-cache/tsconfig.json | 10 + infra/task-cache/types/upstream.d.ts | 12 + .../vite-plus-remote-cache-cloudflare/LICENSE | 21 + .../README.md | 1 + .../migrations/0001_cache.sql | 160 ++++++ .../migrations/0002_pull_requests.sql | 12 + .../package.json | 10 + .../src/admission.ts | 17 + .../src/auth.ts | 166 ++++++ .../src/cbor.ts | 151 ++++++ .../src/database.ts | 291 ++++++++++ .../src/errors.ts | 42 ++ .../src/fetch.ts | 42 ++ .../src/index.ts | 130 +++++ .../src/limits.ts | 42 ++ .../src/multipart.ts | 120 +++++ .../src/observations.ts | 42 ++ .../src/store.ts | 131 +++++ .../src/streams.ts | 136 +++++ knip.jsonc | 13 + pnpm-lock.yaml | 105 ++++ vite.config.ts | 15 + 67 files changed, 5560 insertions(+), 1 deletion(-) create mode 100644 .task-cache.json create mode 100644 infra/task-cache/README.md create mode 100644 infra/task-cache/alchemy.run.ts create mode 100644 infra/task-cache/install.sh create mode 100644 infra/task-cache/migrations/savings/0001_savings.sql create mode 100644 infra/task-cache/migrations/savings/0002_unspecified_tier.sql create mode 100644 infra/task-cache/migrations/savings/0003_remove_setup_probes.sql create mode 100644 infra/task-cache/package.json create mode 100644 infra/task-cache/scripts/install-ci.ts create mode 100644 infra/task-cache/scripts/install.ts create mode 100644 infra/task-cache/scripts/manage.ts create mode 100644 infra/task-cache/scripts/smoke.ts create mode 100644 infra/task-cache/src/Admission.test.ts create mode 100644 infra/task-cache/src/Admission.ts create mode 100644 infra/task-cache/src/Cache.test.ts create mode 100644 infra/task-cache/src/Cache.ts create mode 100644 infra/task-cache/src/Savings.test.ts create mode 100644 infra/task-cache/src/Savings.ts create mode 100644 infra/task-cache/src/Summary.ts create mode 100644 infra/task-cache/src/WriterRegistry.test.ts create mode 100644 infra/task-cache/src/WriterRegistry.ts create mode 100644 infra/task-cache/src/deploymentConfig.ts create mode 100644 infra/task-cache/src/developerAuth.test.ts create mode 100644 infra/task-cache/src/developerAuth.ts create mode 100644 infra/task-cache/src/flush.ts create mode 100644 infra/task-cache/src/nativeHook.js create mode 100644 infra/task-cache/src/nativeHook.test.ts create mode 100644 infra/task-cache/src/nativeRunHook.js create mode 100644 infra/task-cache/src/observability.ts create mode 100644 infra/task-cache/src/preload.mjs create mode 100644 infra/task-cache/src/protocol.ts create mode 100644 infra/task-cache/src/protocolTypes.ts create mode 100644 infra/task-cache/src/remoteCache.ts create mode 100644 infra/task-cache/src/resources.ts create mode 100644 infra/task-cache/src/runHook.test.ts create mode 100644 infra/task-cache/src/runHook.ts create mode 100644 infra/task-cache/src/telemetry.test.ts create mode 100644 infra/task-cache/src/telemetry.ts create mode 100644 infra/task-cache/src/telemetryAuth.test.ts create mode 100644 infra/task-cache/src/telemetryAuth.ts create mode 100644 infra/task-cache/src/worker.ts create mode 100644 infra/task-cache/tsconfig.json create mode 100644 infra/task-cache/types/upstream.d.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/LICENSE create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/README.md create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0001_cache.sql create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0002_pull_requests.sql create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/package.json create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/admission.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/auth.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/database.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/errors.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/index.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/limits.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/multipart.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/observations.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/store.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/streams.ts diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index 7a6a1aa122a7..e0ef427bd1d3 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -1,5 +1,10 @@ name: Setup CI dependencies description: Install Vite+ and the workspace dependencies from cache. +inputs: + task-cache-read-token: + description: Read-only credential for the private maintainer task cache + required: false + default: "" runs: using: composite steps: @@ -65,3 +70,20 @@ runs: - name: Install dependencies shell: bash run: vp install + + - name: Enable shared task cache + if: >- + github.repository == 'pingdotgg/t3code' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + hashFiles('.task-cache.json') != '' && + inputs.task-cache-read-token != '' + shell: bash + env: + T3_TASK_CACHE_READ_TOKEN: ${{ inputs.task-cache-read-token }} + run: | + node infra/task-cache/scripts/install-ci.ts + if [[ ( "$GITHUB_EVENT_NAME" == pull_request && "$GITHUB_BASE_REF" == main ) || ( "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == refs/heads/main ) ]]; then + echo 'VP_REMOTE_CACHE=read-write' >> "$GITHUB_ENV" + else + echo 'VP_REMOTE_CACHE=read' >> "$GITHUB_ENV" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6707b0151bb3..8023fafd6155 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: # file, docs included. The `Check` job at the end of this file passes only # when every other job passes or was skipped by `changes`. lint: + permissions: + contents: read + id-token: write name: Lint runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 @@ -82,6 +85,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -108,6 +113,9 @@ jobs: run: vp check build: + permissions: + contents: read + id-token: write name: Build and Typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} @@ -139,6 +147,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -197,6 +207,9 @@ jobs: # dependency layers. The concurrency limit stays at the default 4 so peak load # per runner is unchanged. test: + permissions: + contents: read + id-token: write name: Test needs: changes if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} @@ -237,6 +250,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -278,6 +293,9 @@ jobs: # started last and ran alone for about 40s after everything else finished. # release.yml mirrors this test, test_web, and test_server split. test_web: + permissions: + contents: read + id-token: write name: Test Web needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} @@ -295,6 +313,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # The browser project runs React tests in headless Chromium. Same cache key as # test_server, so both jobs share one downloaded browser. @@ -333,6 +353,9 @@ jobs: # The shards are the longest jobs, so they gate themselves instead of waiting # ~15s for `changes`: a shard the change cannot affect stops after checkout. test_server: + permissions: + contents: read + id-token: write name: Test Server ${{ matrix.shard }} runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 @@ -359,6 +382,8 @@ jobs: - name: Setup Vite+ if: steps.changes.outputs.test_server != 'false' uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` @@ -495,6 +520,9 @@ jobs: run: vp run lint:mobile release_smoke: + permissions: + contents: read + id-token: write name: Release Smoke needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} @@ -512,6 +540,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a80220c28a8..d1045a239c87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -245,6 +247,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -275,6 +279,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -309,6 +315,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Resolve Playwright version id: playwright_version @@ -359,6 +367,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` diff --git a/.task-cache.json b/.task-cache.json new file mode 100644 index 000000000000..0e62827f9d64 --- /dev/null +++ b/.task-cache.json @@ -0,0 +1,3 @@ +{ + "endpoint": "https://t3-task-cache.pinglabs.workers.dev/projects/t3code" +} diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 28ecce1a6986..5d36d0867c59 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -62,9 +62,11 @@ const packExecutableTargets = packExecutableTarget // The root config's `projects` route repo-root runs to package configs, // including this one. Inheriting them would make this config a container. const { projects: _rootTestProjects, ...baseTestConfig } = baseConfig.test ?? {}; +// Workspace cache configuration is invalid on a package config. +const { run: _rootRunConfig, ...basePackageConfig } = baseConfig; export default mergeConfig( - { ...baseConfig, test: baseTestConfig }, + { ...basePackageConfig, test: baseTestConfig }, defineConfig({ run: { tasks: { diff --git a/infra/task-cache/README.md b/infra/task-cache/README.md new file mode 100644 index 000000000000..55633992e9bb --- /dev/null +++ b/infra/task-cache/README.md @@ -0,0 +1,77 @@ +# Shared Vite Task cache + +[Alchemy](https://alchemy.run) manages the `T3TaskCache` stack: the `t3-task-cache` Worker, its D1 index, private R2 bucket, rate limits, and cleanup schedule. The production stack adopts the existing resources and preserves their data and repository authorization policy. D1 and R2 have a retain removal policy. + +The Worker uses Effect services for request handling, developer authentication, bounded concurrency, deadlines, and cleanup. The pinned [upstream protocol package](https://github.com/voidzero-dev/vite-plus-remote-cache-cloudflare/tree/5b06c2c10d84ea441735ba07c46f6d1b7cb2ff3b) supplies the experimental wire format, streaming upload, GitHub OIDC validation, and atomic D1 publication. Keeping that adapter preserves compatibility with existing cache entries. + +Vite+ 1.3 includes an experimental remote client even though its TypeScript configuration and CLI help omit it. The native client reads `run.cache.remote.url`, `VP_REMOTE_CACHE_URL`, and `VP_REMOTE_CACHE`. An opt-in Node preload patches the Vite+ JavaScript entrypoint in memory and invokes an optional repository hook before and after the native task runner. The global executable and PATH are unchanged. Vite config loads saved credentials into its in-memory remote URL, and the hook selects the native cache mode and exports task summaries. Saved credentials are never exported into task environments. Normal dependency installs do not patch Vite+. The hook is installed only by explicit maintainer setup or our own CI; without credentials it does not collect or queue telemetry. + +## Deploy + +Install dependencies with `vp install`, then authenticate with Alchemy's Cloudflare profile. Alternatively, provide `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` through your environment. Alchemy stores deployment state remotely with `Cloudflare.state()`. + +```sh +cd infra/task-cache +alchemy deploy --stage prod +``` + +The stack reads read-only credential digests from `~/.config/t3-task-cache/readers.json` (or `READER_KEYS`) and writer digests from `~/.config/t3-task-cache/writers.json` and supplies them as a redacted Worker secret. Preserve that file to administer existing writers. Deployment uses the `prod` stage and the existing endpoint in `.task-cache.json`. The upstream protocol is loaded only inside the Worker runtime and bundled by Alchemy, so deployment runs on Node without an extra TypeScript loader. An explicit `DEVELOPER_KEYS` environment variable overrides the local registry, including on deployment hosts without that file. Deployment fails when neither source exists, so a missing local file cannot silently revoke existing writers. + +The production database’s `scopes` and `deployment` records control repository identity, push branch, quotas, and retention. `pull_requests` explicitly enables same-repository PR uploads targeting the configured `branch`; `writes_enabled` disables all artifact uploads, including those from maintainers. Telemetry accepts reports from other PR bases without granting artifact writes. Provisioning a separate cache requires its own repository policy; a new empty database does not grant uploads. + +The CI read identity is named `ci-reader`. Its raw token belongs in the GitHub repository secret `T3_TASK_CACHE_READ_TOKEN`; `readers.json` maps that name to the token’s SHA-256 hex digest. To rotate it, replace that digest, deploy, then replace the GitHub secret. Keep the optional private `ci-reader.json` file as `{ "name": "ci-reader", "token": "…" }` for the smoke test to verify read-only restoration; without it, smoke uses the operator’s saved credential. Never commit these files. The read credential cannot upload artifacts or telemetry. Release jobs lack OIDC and their telemetry is not recorded. + +## Add a contributor or machine + +```sh +node infra/task-cache/scripts/manage.ts add-writer maria +``` + +This updates the local writer registry and writes `~/.config/t3-task-cache/maria.json`. Run `alchemy deploy --stage prod` from `infra/task-cache` to apply the registry, then transfer that file through a private channel. The recipient runs: + +```sh +bash infra/task-cache/install.sh +``` + +The installer prompts for the endpoint, developer name, and token (hidden), verifies authentication with an empty telemetry report, and saves credentials with mode `0600`. It installs the hook and credentials once in the home directory and enables the preload through `NODE_OPTIONS` in shell startup files. Open a fresh shell after setup. New worktrees and dependency reinstalls automatically use the same hook; no checkout or executable is modified. Existing long-running agents need to restart or inherit the updated environment. Rerunning setup updates the shared hook and restores matching backups from the old on-disk patch. + +Uninstall with `bash infra/task-cache/install.sh --uninstall`, then restart existing shells and agents. This removes shell integration, saved client credentials, and queued telemetry. A no-op preload remains for already-running shells that still inherit its path; no cache code runs through it. Administrative writer digests and issued credential files are preserved. + +Explicit environment settings take precedence: `VP_REMOTE_CACHE_URL` may contain the URL-encoded developer name and token as HTTP Basic credentials, and `VP_REMOTE_CACHE=read-write` enables uploads. These can be supplied by a secret manager instead of saved credentials. Do not put credentials in shell command history or commit them. Use separate writer names for separate people. Repeating `add-writer` rotates the token; `revoke-writer ` removes it. Deploy again to apply either change. + +Our repository’s CI explicitly installs the Vite+ hook. Same-repository PRs targeting `main` and default-branch push jobs read and write. PRs targeting other branches read only; fork PRs receive no cache credentials and run normally without remote caching. All artifact reads require authentication, including downloads by blob ID. Trusted maintainers automatically upload artifacts that other authenticated maintainers and CI consume. Deployment credentials are never needed on contributor machines or CI runners. + +## Verify + +```sh +node infra/task-cache/scripts/smoke.ts +vp run --filter @t3tools/task-cache test +vp run --filter @t3tools/task-cache typecheck +``` + +The smoke check uploads a unique fixture, restores its output into a cold second directory, changes its input, and confirms recomputation. It also checks that anonymous reads and uploads are rejected. Cacheable commands must run through `vp run`; direct `vp test` or `vp check` invocations do not use this cache. + +## Current limits + +- Vite Task separates operating systems and CPU architectures. macOS results do not satisfy Linux CI; Linux developer machines can populate compatible CI results. +- The current protocol retains one result for each execution key. Competing source versions can overwrite each other's entry. Inputs are validated before reuse, so this causes misses rather than stale hits. Merging to main does not promote or reset the cache. +- A local hit makes no remote request and is not uploaded retroactively. Clear only an isolated worktree's task cache when testing uploads of an already-cached task. +- Production retains results for seven days within an 8 GB budget. Remote errors fall back to execution. `VP_REMOTE_CACHE=off vp run ...` disables the remote tier for one invocation. + +## Savings and tracing + +Alchemy also owns the `t3-task-cache-traces` Axiom dataset, its ingest-only Worker token, a savings dashboard, and a separate retained D1 database for lifetime counters. Deployment needs an Axiom Alchemy profile or `AXIOM_TOKEN`/`AXIOM_ORG_ID` as well as Cloudflare credentials. Contributor machines never receive the Axiom token. + +Both `vp run` and its `vpr` shortcut invoke the hook. The hook observes Vite+'s atomic `last-summary.json` files, including nested runs. It reports package/task names, confirmed local and remote hits, avoided task runtime, developer identity, host, OS/architecture, and CI run ID. It does not send commands, arguments, source paths, environment variables, or task output. Developer identity comes from the authenticated cache writer; CI uses GitHub's verified OIDC actor. Same-repository PRs may upload artifacts as well as telemetry. Fork jobs do not install the hook or report telemetry. + +Reports spool locally and retry on subsequent commands. The completion hook queues the final summary, including failed runs. Local runs start a detached, short-lived Node process; CI awaits its bounded flush before the runner can terminate it. Collection or export failures never change the task exit status. No background service is installed. Queues retain at most 2,000 reports, each sent with a three-second timeout and a 15-second budget per flush. Stable report IDs and transactional D1 receipts prevent duplicate lifetime increments. Receipts expire after 31 days; accepted reports must be less than 30 days old. Lifetime totals survive raw trace expiration and are re-exported hourly, including inactive developers and hosts. Collection starts when the hook is installed; existing cache history is not backfilled. + +**Saved time means historical task execution time avoided.** It is useful as a compute proxy, but it is not measured CPU time, money saved, or elapsed developer waiting time. Parallel tasks can overlap, and cache lookup/restore overhead is not subtracted. Failed restores contribute no savings. Older Vite+ versions omit the hit source; their confirmed savings are counted under an explicit unspecified tier. Native summary collection is best-effort: interrupted runs or summaries overwritten before observation can be missing. The experimental Vite+ summary schema is tested here and must be checked when upgrading Vite+. + +Host labels use `~/.agents/MACHINE` when present, otherwise the hostname. GitHub-hosted runners use their runner OS/architecture class to avoid a new lifetime series for every ephemeral VM. Set `T3_TASK_CACHE_HOST` to override the label, or `T3_TASK_CACHE_TELEMETRY=off` to opt out while retaining remote caching. Re-run the installer to update an existing machine's hook. + +Writer administration requires the complete private registry on the administering machine. Copy that registry securely from its administrator before adding a writer on another machine. `add-writer --new-registry` explicitly initializes a new cache; never use it to recover an existing registry. + +Node processes running with filesystem permissions must allow the installed preload and hook directory. Remove the task-cache import from `NODE_OPTIONS` for a restricted process that cannot allow those files. + +Local telemetry is queued to disk before command exit and uploaded by a short-lived detached Node process. Slow or offline endpoints do not delay `vp run`; unsent reports retry on a later run. diff --git a/infra/task-cache/alchemy.run.ts b/infra/task-cache/alchemy.run.ts new file mode 100644 index 000000000000..1de471ade0ed --- /dev/null +++ b/infra/task-cache/alchemy.run.ts @@ -0,0 +1,28 @@ +import { adopt } from "alchemy/AdoptPolicy"; +import * as Alchemy from "alchemy"; +import * as Axiom from "alchemy/Axiom"; +import * as Layer from "effect/Layer"; +import { Observability } from "./src/observability.ts"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Secrets from "alchemy/Secrets"; +import { WriterSecrets } from "./src/deploymentConfig.ts"; +import * as Worker from "./src/worker.ts"; + +export default Alchemy.Stack( + "T3TaskCache", + { + providers: Axiom.providers().pipe(Layer.provideMerge(Cloudflare.providers())), + state: Cloudflare.state(), + secrets: [Secrets.DotEnv(), Secrets.ProcessEnv(), WriterSecrets], + }, + Effect.gen(function* () { + const worker = yield* Worker.Worker; + const observability = yield* Observability; + return { + url: worker.url, + workerName: worker.workerName, + dashboard: observability.dashboard.uid, + }; + }).pipe(Effect.provide(Worker.layer), adopt(true)), +); diff --git a/infra/task-cache/install.sh b/infra/task-cache/install.sh new file mode 100644 index 000000000000..eebce1917ad3 --- /dev/null +++ b/infra/task-cache/install.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec node "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/scripts/install.ts" "$@" diff --git a/infra/task-cache/migrations/savings/0001_savings.sql b/infra/task-cache/migrations/savings/0001_savings.sql new file mode 100644 index 000000000000..48701f45542f --- /dev/null +++ b/infra/task-cache/migrations/savings/0001_savings.sql @@ -0,0 +1,16 @@ +CREATE TABLE receipts (id TEXT PRIMARY KEY, recorded_at INTEGER NOT NULL) WITHOUT ROWID; +CREATE TABLE savings ( + developer TEXT NOT NULL, + host TEXT NOT NULL, + platform TEXT NOT NULL, + arch TEXT NOT NULL, + ci INTEGER NOT NULL, + local_ms INTEGER NOT NULL, + remote_ms INTEGER NOT NULL, + local_hits INTEGER NOT NULL, + remote_hits INTEGER NOT NULL, + executed INTEGER NOT NULL, + restore_failures INTEGER NOT NULL, + PRIMARY KEY (developer, host, platform, arch, ci) +) WITHOUT ROWID; +CREATE INDEX receipts_age ON receipts(recorded_at); diff --git a/infra/task-cache/migrations/savings/0002_unspecified_tier.sql b/infra/task-cache/migrations/savings/0002_unspecified_tier.sql new file mode 100644 index 000000000000..9f3bf5e69143 --- /dev/null +++ b/infra/task-cache/migrations/savings/0002_unspecified_tier.sql @@ -0,0 +1,2 @@ +ALTER TABLE savings ADD COLUMN unspecified_ms INTEGER NOT NULL DEFAULT 0; +ALTER TABLE savings ADD COLUMN unspecified_hits INTEGER NOT NULL DEFAULT 0; diff --git a/infra/task-cache/migrations/savings/0003_remove_setup_probes.sql b/infra/task-cache/migrations/savings/0003_remove_setup_probes.sql new file mode 100644 index 000000000000..2bd529ebbbb1 --- /dev/null +++ b/infra/task-cache/migrations/savings/0003_remove_setup_probes.sql @@ -0,0 +1,4 @@ +-- Empty installer authentication probes are not task execution history. +DELETE FROM savings +WHERE local_hits = 0 AND remote_hits = 0 AND unspecified_hits = 0 + AND executed = 0 AND restore_failures = 0; diff --git a/infra/task-cache/package.json b/infra/task-cache/package.json new file mode 100644 index 000000000000..03c7776baa77 --- /dev/null +++ b/infra/task-cache/package.json @@ -0,0 +1,25 @@ +{ + "name": "@t3tools/task-cache", + "private": true, + "type": "module", + "scripts": { + "test": "vp test run --config ../../vite.config.ts --dir .", + "typecheck": "tsc --noEmit", + "smoke": "node scripts/smoke.ts", + "deploy": "alchemy deploy --stage prod", + "writers": "node scripts/manage.ts" + }, + "dependencies": { + "alchemy": "2.0.0-beta.80", + "effect": "catalog:", + "jose": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260601.1", + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/infra/task-cache/scripts/install-ci.ts b/infra/task-cache/scripts/install-ci.ts new file mode 100644 index 000000000000..0561507de79f --- /dev/null +++ b/infra/task-cache/scripts/install-ci.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off - installs the hook before running project tasks. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +if (process.env.GITHUB_REPOSITORY !== "pingdotgg/t3code" || process.env.GITHUB_ACTIONS !== "true") + throw new Error("This setup is only for the maintainer repository's GitHub Actions."); +const root = NodePath.resolve(import.meta.dirname, "../../.."); +const hookConfig = NodePath.join(NodeOS.homedir(), ".config/vite-plus/run-hook.json"); +await NodeFSP.mkdir(NodePath.dirname(hookConfig), { recursive: true }); +await NodeFSP.writeFile( + hookConfig, + JSON.stringify({ + rootMarker: ".task-cache.json", + module: NodePath.join(root, "infra/task-cache/src/runHook.ts"), + }), +); +const { endpoint } = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, ".task-cache.json"), "utf8"), +); +const token = process.env.T3_TASK_CACHE_READ_TOKEN; +if (!token || token.length < 32) throw new Error("Missing task-cache read credential"); +const credentialFile = NodePath.join(NodeOS.homedir(), ".config/t3-task-cache/credentials.json"); +await NodeFSP.mkdir(NodePath.dirname(credentialFile), { recursive: true, mode: 0o700 }); +await NodeFSP.writeFile( + credentialFile, + JSON.stringify({ [endpoint]: { name: "ci-reader", token } }), + { mode: 0o600 }, +); +const githubEnv = process.env.GITHUB_ENV; +if (!githubEnv) throw new Error("Missing GitHub Actions environment file"); +const directory = process.env.RUNNER_TEMP; +if (!directory) throw new Error("Missing GitHub Actions temporary directory"); +const shellSetup = NodePath.join(directory, "t3-task-cache.sh"); +const quote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; +const preload = NodeURL.pathToFileURL(NodePath.join(root, "infra/task-cache/src/preload.mjs")).href; +// GitHub prohibits NODE_OPTIONS in GITHUB_ENV; bash exports it to task subprocesses instead. +await NodeFSP.writeFile( + shellSetup, + [ + process.env.BASH_ENV && process.env.BASH_ENV !== shellSetup + ? `. ${quote(process.env.BASH_ENV)}` + : "", + `export NODE_OPTIONS="\${NODE_OPTIONS:+$NODE_OPTIONS }"${quote(`--import=${preload}`)}`, + "", + ].join("\n"), +); +await NodeFSP.appendFile(githubEnv, `BASH_ENV=${shellSetup}\n`); diff --git a/infra/task-cache/scripts/install.ts b/infra/task-cache/scripts/install.ts new file mode 100644 index 000000000000..5826189de764 --- /dev/null +++ b/infra/task-cache/scripts/install.ts @@ -0,0 +1,307 @@ +/* oxlint-disable t3code/no-global-process-runtime -- Standalone setup must run before workspace dependencies are installed. */ +// @effect-diagnostics nodeBuiltinImport:off - standalone bootstrap also runs before dependencies are installed. +// @effect-diagnostics globalConsole:off globalFetch:off globalDate:off - interactive setup and endpoint verification. +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeReadlinePromises from "node:readline/promises"; +import { patchNativeCli } from "../src/nativeHook.js"; + +const home = NodeOS.homedir(); +const root = NodePath.resolve(import.meta.dirname, "../../.."); +const state = NodePath.join(home, ".config/t3-task-cache"); +const nonInteractive = process.argv.includes("--non-interactive"); +const installed = NodePath.join(home, ".local/share/t3-task-cache"); +const hookConfig = NodePath.join(home, ".config/vite-plus/run-hook.json"); +const legacySourceLine = '. "$HOME/.config/t3-task-cache/env"'; +const sourceLine = + '[ ! -r "$HOME/.config/t3-task-cache/env" ] || . "$HOME/.config/t3-task-cache/env"'; +const profiles = [".profile", ".bashrc", ".bash_profile", ".bash_login", ".zshenv", ".zshrc"]; +if (process.argv.includes("--uninstall")) { + for (const file of profiles) { + const path = NodePath.join(home, file); + try { + const original = await NodeFSP.readFile(path, "utf8"); + const next = original + .split("\n") + .filter((line) => line !== sourceLine && line !== legacySourceLine) + .join("\n"); + if (next !== original) { + if (!next.trim() && (file === ".bash_profile" || file === ".bash_login")) + await NodeFSP.unlink(path); + else await NodeFSP.writeFile(path, next); + } + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + } + try { + const config = JSON.parse(await NodeFSP.readFile(hookConfig, "utf8")); + if (config.module === NodePath.join(installed, "runHook.ts")) await NodeFSP.unlink(hookConfig); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + await NodeFSP.rm(NodePath.join(state, "credentials.json"), { force: true }); + await NodeFSP.rm(NodePath.join(state, "env"), { force: true }); + await NodeFSP.rm(NodePath.join(home, ".cache/t3-task-cache/telemetry"), { + recursive: true, + force: true, + }); + // Existing shells still import this path until restarted; leave a harmless loader for them. + await NodeFSP.mkdir(installed, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(installed, "preload.mjs"), + "// Task cache uninstalled. Safe for shells opened before uninstall.\n", + ); + for (const file of await NodeFSP.readdir(installed)) { + if (file !== "preload.mjs") + await NodeFSP.rm(NodePath.join(installed, file), { recursive: true, force: true }); + } + console.log( + "Task cache uninstalled. Restart existing shells and agents to clear their inherited preload.", + ); + process.exit(0); +} + +const { endpoint: defaultEndpoint } = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, ".task-cache.json"), "utf8"), +); +let credentials: Record = {}; +try { + credentials = JSON.parse( + await NodeFSP.readFile(NodePath.join(state, "credentials.json"), "utf8"), + ); +} catch (error) { + if ( + !(error instanceof SyntaxError) && + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) + throw error; +} +const prompt = NodeReadlinePromises.createInterface({ + input: process.stdin, + output: process.stdout, +}); +let endpoint = process.env.T3_CACHE_SETUP_URL || defaultEndpoint; +let name = process.env.T3_CACHE_SETUP_NAME || credentials[endpoint]?.name; +let token = process.env.T3_CACHE_SETUP_TOKEN || credentials[endpoint]?.token; +const initialEndpoint = endpoint; +const initialName = name; +try { + if (!nonInteractive) { + endpoint = (await prompt.question(`Cache URL [${endpoint}]: `)).trim() || endpoint; + name = (await prompt.question(`Developer name${name ? ` [${name}]` : ""}: `)).trim() || name; + prompt.close(); + if (process.stdin.isTTY || !token || endpoint !== initialEndpoint || name !== initialName) { + const savedToken = endpoint === initialEndpoint && name === initialName ? token : undefined; + if (!process.stdin.isTTY) + throw new Error("Use T3_CACHE_SETUP_TOKEN for non-interactive setup."); + process.stdout.write( + `Credential token (hidden)${savedToken ? " [Enter keeps saved token]" : ""}: `, + ); + process.stdin.setRawMode(true); + process.stdin.resume(); + token = + (await new Promise((resolve, reject) => { + let value = ""; + const data = (chunk: Buffer) => { + for (const character of chunk.toString()) { + if (character === "\u0003") { + cleanup(); + reject(new Error("Cancelled")); + return; + } + if (character === "\r" || character === "\n") { + cleanup(); + resolve(value); + return; + } + if (character === "\u007f") value = value.slice(0, -1); + else value += character; + } + }; + const cleanup = () => { + process.stdin.off("data", data); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\n"); + }; + process.stdin.on("data", data); + })) || savedToken; + } + } +} finally { + prompt.close(); +} +if (!name || !token || !/^[a-z0-9-]{1,64}$/.test(name) || token.length < 32) + throw new Error("A valid developer name and credential token are required."); +const url = new URL(endpoint); +if ( + (url.protocol !== "https:" && + !( + url.protocol === "http:" && + (url.hostname === "localhost" || + /^127(?:\.\d{1,3}){3}$/.test(url.hostname) || + url.hostname === "[::1]") + )) || + url.username || + url.password || + url.search || + url.hash +) + throw new Error( + "Use an HTTPS cache endpoint without embedded credentials, query, or fragment (HTTP is only allowed on loopback).", + ); +endpoint = url.href.replace(/\/$/, ""); +const response = await fetch(`${endpoint}/telemetry`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${name}:${token}`).toString("base64")}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: NodeCrypto.randomBytes(32).toString("hex"), + recordedAt: Date.now(), + host: NodeOS.hostname(), + platform: NodeOS.platform(), + arch: NodeOS.arch(), + ci: false, + runId: "setup", + tasks: [], + }), + signal: AbortSignal.timeout(10_000), +}); +await response.body?.cancel(); +if (!response.ok) + throw new Error(`Credential verification failed (${response.status}); nothing was installed.`); + +const candidates = [NodePath.join(root, "node_modules/vite-plus/dist/bin.js")]; +for (const directory of [ + process.env.VITE_PLUS_HOME, + NodePath.join(home, ".vite-plus/current"), + NodePath.join(home, ".local/share/vite-plus/current"), +]) { + if (directory) candidates.push(NodePath.join(directory, "node_modules/vite-plus/dist/bin.js")); +} +for (let index = 0; index < process.argv.length; index++) { + if (process.argv[index] === "--entrypoint") { + const entrypoint = process.argv[++index]; + if (!entrypoint) throw new Error("Missing --entrypoint path"); + candidates.push(entrypoint); + } +} +const targets = new Map(); +for (const candidate of candidates) { + try { + const path = await NodeFSP.realpath(candidate); + const original = await NodeFSP.readFile(path, "utf8"); + const backup = await NodeFSP.readFile(`${path}.before-task-cache`, "utf8").catch( + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined; + throw error; + }, + ); + if (backup && (original === backup || original === patchNativeCli(backup))) + targets.set(path, { original: backup, patched: original }); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } +} + +await NodeFSP.mkdir(installed, { recursive: true }); +await NodeFSP.writeFile(NodePath.join(installed, "package.json"), '{"type":"module"}\n'); +for (const file of [ + "runHook.ts", + "remoteCache.ts", + "flush.ts", + "telemetry.ts", + "nativeHook.js", + "nativeRunHook.js", + "preload.mjs", +]) { + const destination = NodePath.join(installed, file); + const temporary = `${destination}.${NodeCrypto.randomUUID()}.tmp`; + try { + await NodeFSP.copyFile(NodePath.join(import.meta.dirname, "../src", file), temporary); + await NodeFSP.rename(temporary, destination); + } finally { + await NodeFSP.rm(temporary, { force: true }); + } +} +await NodeFSP.mkdir(NodePath.dirname(hookConfig), { recursive: true }); +await NodeFSP.writeFile( + hookConfig, + JSON.stringify({ + rootMarker: ".task-cache.json", + module: NodePath.join(installed, "runHook.ts"), + }), +); +for (const [path, { original }] of targets) { + await NodeFSP.writeFile(path, original); + await NodeFSP.rm(`${path}.before-task-cache`, { force: true }); + await NodeFSP.rm(NodePath.join(NodePath.dirname(path), "run-hook.js"), { force: true }); +} +for (const file of [ + "nativeHook.ts", + "launcher-telemetry.ts", + "launcherTelemetry.ts", + "vp.ts", + "bin", +]) + await NodeFSP.rm(NodePath.join(installed, file), { recursive: true, force: true }); +await NodeFSP.mkdir(state, { recursive: true, mode: 0o700 }); +credentials[endpoint] = { name, token }; +const temporaryCredentials = NodePath.join(state, `.credentials-${NodeCrypto.randomUUID()}.tmp`); +try { + await NodeFSP.writeFile(temporaryCredentials, JSON.stringify(credentials), { mode: 0o600 }); + await NodeFSP.rename(temporaryCredentials, NodePath.join(state, "credentials.json")); +} finally { + await NodeFSP.rm(temporaryCredentials, { force: true }); +} + +const environmentFile = NodePath.join(state, "env"); +const preload = NodeURL.pathToFileURL(NodePath.join(installed, "preload.mjs")).href; +await NodeFSP.writeFile( + environmentFile, + [ + `if [ -r "${NodePath.join(installed, "preload.mjs")}" ]; then`, + `case " \${NODE_OPTIONS-} " in`, + ` *" --import=${preload} "*) ;;`, + ` *) export NODE_OPTIONS="\${NODE_OPTIONS:+$NODE_OPTIONS }--import=${preload}" ;;`, + `esac`, + `fi`, + "", + ].join("\n"), + { mode: 0o600 }, +); +for (const file of profiles) { + const path = NodePath.join(home, file); + let original = ""; + try { + original = await NodeFSP.readFile(path, "utf8"); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + if (file === ".bash_profile" || file === ".bash_login") { + if (!original) continue; + // Earlier installers created a profile that masks the user’s existing login profile. + if (file === ".bash_profile" && [sourceLine, legacySourceLine].includes(original.trim())) { + await NodeFSP.unlink(path); + continue; + } + } + if (original.split("\n").includes(legacySourceLine)) { + original = original + .split("\n") + .filter((line) => line !== legacySourceLine) + .join("\n"); + await NodeFSP.writeFile(path, original); + } + if (!original.split("\n").includes(sourceLine)) + await NodeFSP.appendFile(path, `\n${sourceLine}\n`); +} +console.log( + "Verified credentials and installed the machine-wide Vite+ hook. Open a fresh shell to enable it in all worktrees. Executables and PATH are unchanged.", +); diff --git a/infra/task-cache/scripts/manage.ts b/infra/task-cache/scripts/manage.ts new file mode 100644 index 000000000000..212d633cadd1 --- /dev/null +++ b/infra/task-cache/scripts/manage.ts @@ -0,0 +1,72 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Schema from "effect/Schema"; +import * as Console from "effect/Console"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Flag from "effect/cli/Flag"; +import * as Argument from "effect/cli/Argument"; +import * as Command from "effect/cli/Command"; + +import * as WriterRegistry from "../src/WriterRegistry.ts"; + +const decodeDeployment = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ url: Schema.String })), +); + +const name = Argument.String("name").pipe(Argument.withDescription("Lowercase writer name")); +const updateWriter = Effect.fn("TaskCache.manageWriter")(function* ( + name: string, + revoke: boolean, + initialize = false, +) { + const registry = yield* WriterRegistry.WriterRegistry; + let endpoint: string | undefined; + if (!revoke && WriterRegistry.isWriterName(name)) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner.string( + ChildProcess.make( + "node", + [ + `${import.meta.dirname}/../node_modules/alchemy/bin/cli.js`, + "state", + "read", + "T3TaskCache/prod/output", + "--backend", + "cloudflare", + "--no-input", + ], + { cwd: `${import.meta.dirname}/..`, stderr: "inherit" }, + ), + ); + const deployed = yield* decodeDeployment(output); + endpoint = new URL("/projects/t3code", deployed.url).href; + } + const file = yield* registry.update(name, revoke, endpoint, initialize); + yield* Console.log( + file === undefined + ? `Removed ${name} from the local writer registry.` + : `Private credential written to ${file}.`, + ); + yield* Console.log( + "Run alchemy deploy --stage prod from infra/task-cache to apply the writer changes.", + ); +}); + +const command = Command.make("task-cache").pipe( + Command.withSubcommands([ + Command.make( + "add-writer", + { name, newRegistry: Flag.Boolean("new-registry").pipe(Flag.withDefault(false)) }, + ({ name, newRegistry }) => updateWriter(name, false, newRegistry), + ), + Command.make("revoke-writer", { name }, ({ name }) => updateWriter(name, true)), + ]), +); + +Command.run(command, { version: "1.0.0" }).pipe( + Effect.provide(WriterRegistry.layer.pipe(Layer.provideMerge(NodeServices.layer))), + NodeRuntime.runMain, +); diff --git a/infra/task-cache/scripts/smoke.ts b/infra/task-cache/scripts/smoke.ts new file mode 100644 index 000000000000..79320ad10546 --- /dev/null +++ b/infra/task-cache/scripts/smoke.ts @@ -0,0 +1,130 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercises real child processes and isolated workspaces. +// @effect-diagnostics globalConsole:off - emits the deployment acceptance transcript. +// @effect-diagnostics globalFetch:off - probes the deployed Worker directly. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +const project = NodePath.join(import.meta.dirname, "../../.."); +const env = { + ...process.env, + T3_TASK_CACHE_TELEMETRY: "off", + VP_REMOTE_CACHE: "read-write", + VP_REMOTE_CACHE_URL: undefined, +}; +const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-cache-probe-")); +const name = `t3-cache-probe-${NodeCrypto.randomUUID()}`; + +async function run(directory: string, home = NodeOS.homedir()) { + return new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + NodePath.join(project, "node_modules/.bin/vp"), + ["run", "--cache", "build"], + { + cwd: NodePath.join(root, directory), + env: { + ...env, + HOME: home, + PATH: `${NodePath.join(project, "node_modules/.bin")}${NodePath.delimiter}${process.env.PATH}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.on("error", reject); + child.on("close", (code) => (code === 0 ? resolve(output) : reject(new Error(output)))); + }); +} + +try { + const { endpoint } = JSON.parse( + await NodeFSP.readFile(NodePath.join(project, ".task-cache.json"), "utf8"), + ); + for (const [path, method] of [ + ["fetch", "POST"], + ["blob/12345678-1234-1234-1234-123456789abc", "GET"], + ] as const) { + const response = await fetch(`${endpoint}/${path}`, { method }); + await response.body?.cancel(); + if (response.status !== 401) + throw new Error(`Anonymous ${path} returned ${response.status}, expected 401.`); + } + const unauthorized = await fetch(`${endpoint}/store`, { method: "POST" }); + await unauthorized.body?.cancel(); + if (unauthorized.status !== 401) + throw new Error(`Anonymous upload returned ${unauthorized.status}, expected 401.`); + for (const directory of ["a", "b"]) { + const cwd = NodePath.join(root, directory); + await NodeFSP.mkdir(cwd); + await NodeFSP.writeFile(NodePath.join(cwd, ".task-cache.json"), JSON.stringify({ endpoint })); + await NodeFSP.writeFile( + NodePath.join(cwd, "vite.config.ts"), + `import { remoteCacheUrl } from ${JSON.stringify(NodePath.join(project, "infra/task-cache/src/remoteCache.ts"))}; export default {run:{cache:{scripts:true,remote:{url:remoteCacheUrl(import.meta.dirname)}}}}`, + ); + await NodeFSP.writeFile( + NodePath.join(cwd, "package.json"), + JSON.stringify({ + name, + private: true, + scripts: { build: `node build.cjs ${name}` }, + }), + ); + await NodeFSP.writeFile( + NodePath.join(cwd, "build.cjs"), + 'if(process.env.VP_REMOTE_CACHE_URL) throw new Error("Cache URL leaked into task environment"); const fs=require("node:fs"); fs.writeFileSync("output.txt",fs.readFileSync("input.txt")); console.log("computed");', + ); + await NodeFSP.writeFile(NodePath.join(cwd, "input.txt"), "version-a"); + } + console.log("First checkout:\n" + (await run("a"))); + const readerFile = NodePath.join(NodeOS.homedir(), ".config/t3-task-cache/ci-reader.json"); + let reader; + try { + reader = JSON.parse(await NodeFSP.readFile(readerFile, "utf8")); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; + const credentials = JSON.parse( + await NodeFSP.readFile( + NodePath.join(NodeOS.homedir(), ".config/t3-task-cache/credentials.json"), + "utf8", + ), + ); + reader = credentials[endpoint]; + } + const otherHome = NodePath.join(root, "other-developer"); + await NodeFSP.mkdir(NodePath.join(otherHome, ".config/t3-task-cache"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(otherHome, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ + [endpoint]: reader, + }), + ); + const second = await run("b", otherHome); + console.log("Cold second checkout:\n" + second); + if (!second.includes("remote cache hit")) + throw new Error("Second checkout did not reuse the remote result."); + if ((await NodeFSP.readFile(NodePath.join(root, "b/output.txt"), "utf8")) !== "version-a") + throw new Error("Output was not restored."); + await NodeFSP.writeFile(NodePath.join(root, "b/input.txt"), "version-b"); + const changed = await run("b"); + console.log("Changed input:\n" + changed); + if ( + changed.includes("cache hit") || + (await NodeFSP.readFile(NodePath.join(root, "b/output.txt"), "utf8")) !== "version-b" + ) + throw new Error("Changed input reused stale output."); + await NodeFSP.rm(NodePath.join(root, "a/node_modules"), { recursive: true, force: true }); + console.log("Original input after competing writer:\n" + (await run("a"))); + console.log( + "PASS: authenticated upload, cold-checkout restoration, input validation, and anonymous-write rejection.", + ); +} finally { + await NodeFSP.rm(root, { recursive: true, force: true }); +} diff --git a/infra/task-cache/src/Admission.test.ts b/infra/task-cache/src/Admission.test.ts new file mode 100644 index 000000000000..c1fef5204796 --- /dev/null +++ b/infra/task-cache/src/Admission.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Admission from "./Admission.ts"; + +it.effect("queues uploads and releases the permit when an upload is interrupted", () => + Effect.gen(function* () { + const admission = yield* Admission.Admission; + const entered = yield* Deferred.make(); + const secondEntered = yield* Deferred.make(); + const first = yield* admission + .withPermit(true, Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never))) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + const second = yield* admission + .withPermit( + true, + Deferred.succeed(secondEntered, undefined).pipe(Effect.andThen(Effect.never)), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(secondEntered); + const third = yield* admission + .withPermit(true, Effect.succeed("restored")) + .pipe(Effect.forkChild); + yield* Fiber.interrupt(first); + expect(yield* Fiber.join(third)).toBe("restored"); + yield* Fiber.interrupt(second); + }).pipe(Effect.provide(Admission.layer)), +); + +it.effect("reads continue while all upload slots are occupied", () => + Effect.gen(function* () { + const admission = yield* Admission.Admission; + const entered = yield* Deferred.make(); + const occupied = yield* admission + .withPermit( + true, + admission.withPermit( + true, + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + expect(yield* admission.withPermit(false, Effect.succeed("hit"))).toBe("hit"); + yield* Fiber.interrupt(occupied); + }).pipe(Effect.provide(Admission.layer)), +); diff --git a/infra/task-cache/src/Admission.ts b/infra/task-cache/src/Admission.ts new file mode 100644 index 000000000000..4f69041e8b49 --- /dev/null +++ b/infra/task-cache/src/Admission.ts @@ -0,0 +1,28 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; + +export class Admission extends Context.Service< + Admission, + { + readonly withPermit: ( + store: boolean, + task: Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/task-cache/Admission") {} + +const make = Effect.gen(function* () { + // Bound simultaneous upload buffers; Effect cancels queued waiters and releases permits. + const stores = yield* Semaphore.make(2); + const reads = yield* Semaphore.make(4); + return Admission.of({ + // The caller owns the request span; admission adds no second span per request. + withPermit: Effect.fnUntraced(function* (store, task) { + return yield* (store ? stores : reads).withPermits(1)(task); + }), + }); +}); + +export const layer = Layer.effect(Admission, make); diff --git a/infra/task-cache/src/Cache.test.ts b/infra/task-cache/src/Cache.test.ts new file mode 100644 index 000000000000..7ff2a8fb7687 --- /dev/null +++ b/infra/task-cache/src/Cache.test.ts @@ -0,0 +1,324 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import { Deadline } from "../vendor/vite-plus-remote-cache-cloudflare/src/streams.ts"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; +import { Observations } from "../vendor/vite-plus-remote-cache-cloudflare/src/observations.ts"; +import * as Admission from "./Admission.ts"; +import * as Cache from "./Cache.ts"; +import * as Savings from "./Savings.ts"; + +vi.mock("../vendor/vite-plus-remote-cache-cloudflare/src/database.ts", () => ({ + getScope: async () => ({}), + selectBlob: async () => ({ blob_object: "artifact", blob_size: 4 }), + cleanup: async () => {}, +})); + +it.effect("includes successful blob downloads in exported R2 usage", () => + Effect.gen(function* () { + const get = vi.fn(async () => ({ size: 4, body: new Blob(["data"]).stream() })); + const bindings = { + developerKeys: JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + ARTIFACTS: { get }, + readLimit: { limit: async () => ({ success: true }) }, + } as unknown as Cache.Bindings["Service"]; + using observations = vi.spyOn(Observations.prototype, "toJSON"); + const response = yield* Effect.gen(function* () { + const cache = yield* Cache.Cache; + return yield* cache.handle( + new Request( + "https://cache.example/projects/t3code/blob/12345678-1234-1234-1234-123456789abc", + { + headers: { + authorization: `Basic ${btoa("julius:fixture-token-012345678901234567890123456789")}`, + }, + }, + ), + { waitUntil: () => {} }, + ); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, bindings), + Admission.layer, + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ); + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe("data"); + expect(get).toHaveBeenCalledWith("artifact"); + expect(observations.mock.results.at(-1)?.value.r2_operations).toBe(1); + }), +); + +it.effect("aborts the upstream deadline before disposing it on interruption", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const cancelled: boolean[] = []; + const originalDispose = Deadline.prototype.dispose; + using _dispose = vi.spyOn(Deadline.prototype, "dispose").mockImplementation(function ( + this: Deadline, + ) { + try { + this.check(); + cancelled.push(false); + } catch { + cancelled.push(true); + } + originalDispose.call(this); + }); + const bindings = { + developerKeys: JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + ARTIFACTS: { + get: () => { + Deferred.doneUnsafe(entered, Effect.void); + return new Promise(() => {}); + }, + }, + readLimit: { limit: async () => ({ success: true }) }, + } as unknown as Cache.Bindings["Service"]; + yield* Effect.gen(function* () { + const cache = yield* Cache.Cache; + const fiber = yield* cache + .handle( + new Request( + "https://cache.example/projects/t3code/blob/12345678-1234-1234-1234-123456789abc", + { + headers: { + authorization: `Basic ${btoa("julius:fixture-token-012345678901234567890123456789")}`, + }, + }, + ), + { waitUntil: () => {} }, + ) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + expect(cancelled).toEqual([true]); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, bindings), + Admission.layer, + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ); + }), +); + +it.effect("does not charge authenticated budgets for anonymous or malformed requests", () => { + const limit = vi.fn(async (_options: { key: string }) => ({ success: true })); + return Effect.gen(function* () { + const cache = yield* Cache.Cache; + for (const operation of [ + "fetch", + "store", + "telemetry", + "blob/12345678-1234-1234-1234-123456789abc", + ]) { + for (const method of ["GET", "POST"]) { + yield* cache.handle( + new Request(`https://cache.example/projects/t3code/${operation}`, { method }), + { waitUntil: () => {} }, + ); + } + } + expect(limit).not.toHaveBeenCalled(); + const headers = { + authorization: `Basic ${btoa("julius:fixture-token-012345678901234567890123456789")}`, + }; + yield* cache.handle( + new Request("https://cache.example/projects/t3code/telemetry", { + method: "POST", + headers, + body: "{}", + }), + { waitUntil: () => {} }, + ); + yield* cache.handle( + new Request( + "https://cache.example/projects/t3code/blob/12345678-1234-1234-1234-123456789abc", + { headers }, + ), + { waitUntil: () => {} }, + ); + expect(limit.mock.calls).toEqual([[{ key: "t3code:telemetry" }], [{ key: "t3code:blob" }]]); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, { + developerKeys: JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + readerKeys: "{}", + readLimit: { limit }, + storeLimit: { limit }, + ARTIFACTS: { get: async () => ({ size: 4, body: new Blob(["data"]).stream() }) }, + } as unknown as Cache.Bindings["Service"]), + Admission.layer, + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ); +}); + +it.effect("authenticates setup probes without recording empty savings", () => { + const record = vi.fn(() => Effect.void); + const emitTotals = vi.fn(() => Effect.void); + return Effect.gen(function* () { + const cache = yield* Cache.Cache; + const response = yield* cache.handle( + new Request("https://cache.example/projects/t3code/telemetry", { + method: "POST", + headers: { + authorization: `Basic ${btoa("julius:fixture-token-012345678901234567890123456789")}`, + }, + body: JSON.stringify({ + id: "a".repeat(64), + recordedAt: 0, + host: "setup-host", + platform: "linux", + arch: "x64", + ci: false, + runId: "setup", + tasks: [], + }), + }), + { waitUntil: () => {} }, + ); + expect(response.status).toBe(204); + expect(record).not.toHaveBeenCalled(); + expect(emitTotals).not.toHaveBeenCalled(); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, { + developerKeys: JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + readLimit: { limit: async () => ({ success: true }) }, + } as unknown as Cache.Bindings["Service"]), + Admission.layer, + Layer.succeed(Savings.Savings, { record, emitTotals, cleanup: Effect.void }), + ]), + ), + ), + ); +}); + +it.effect("keeps artifacts private and limits CI reader credentials to reads", () => + Effect.gen(function* () { + const cache = yield* Cache.Cache; + const context = { waitUntil: () => {} }; + const blob = "https://cache.example/projects/t3code/blob/12345678-1234-1234-1234-123456789abc"; + for (const url of [blob, "https://cache.example/projects/t3code/fetch"]) { + const method = url === blob ? "GET" : "POST"; + expect((yield* cache.handle(new Request(url, { method }), context)).status).toBe(401); + expect( + (yield* cache.handle( + new Request(url, { + method, + headers: { authorization: `Basic ${btoa("ci-reader:wrong")}` }, + }), + context, + )).status, + ).toBe(403); + } + const headers = { + authorization: `Basic ${btoa("ci-reader:fixture-token-012345678901234567890123456789")}`, + }; + expect((yield* cache.handle(new Request(blob, { headers }), context)).status).toBe(200); + expect( + (yield* cache.handle( + new Request("https://cache.example/projects/t3code/store", { method: "POST", headers }), + context, + )).status, + ).toBe(403); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, { + developerKeys: "{}", + readerKeys: JSON.stringify({ + "ci-reader": "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + ARTIFACTS: { get: vi.fn(async () => ({ size: 4, body: new Blob(["data"]).stream() })) }, + readLimit: { limit: async () => ({ success: true }) }, + storeLimit: { limit: async () => ({ success: true }) }, + } as unknown as Cache.Bindings["Service"]), + Admission.layer, + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ), +); + +it.effect("rejects anonymous and invalid Basic requests before admission", () => + Effect.gen(function* () { + const cache = yield* Cache.Cache; + for (const operation of ["store", "telemetry", "fetch"]) { + for (const authorization of [undefined, `Basic ${btoa("attacker:invalid")}`]) { + const response = yield* cache.handle( + new Request(`https://cache.example/projects/t3code/${operation}`, { + method: "POST", + headers: authorization ? { authorization } : {}, + }), + { waitUntil: () => {} }, + ); + expect(response.status).toBe(authorization ? 403 : 401); + } + } + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, { + developerKeys: "{}", + readerKeys: "{}", + } as Cache.Bindings["Service"]), + Layer.succeed(Admission.Admission, { + withPermit: () => Effect.die("Unauthenticated request reached admission"), + }), + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ), +); diff --git a/infra/task-cache/src/Cache.ts b/infra/task-cache/src/Cache.ts new file mode 100644 index 000000000000..ce9759b498a2 --- /dev/null +++ b/infra/task-cache/src/Cache.ts @@ -0,0 +1,240 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Cloudflare from "@cloudflare/workers-types"; +import { Summary, recordSummary } from "./Summary.ts"; +import * as Savings from "./Savings.ts"; +import type { ProtocolBindings } from "./protocolTypes.ts"; +import * as Admission from "./Admission.ts"; +import { developerIdentity } from "./developerAuth.ts"; + +const decodeSummary = Schema.decodeEffect(Schema.fromJsonString(Summary)); + +export class CacheError extends Schema.TaggedError()("CacheError", { + status: Schema.Number, + reason: Schema.String, +}) {} + +export class Bindings extends Context.Service< + Bindings, + ProtocolBindings & { + readonly developerKeys: string; + readonly readerKeys: string; + readonly readLimit: Cloudflare.RateLimit; + readonly storeLimit: Cloudflare.RateLimit; + } +>()("@t3tools/task-cache/Cache/Bindings") {} + +export class Cache extends Context.Service< + Cache, + { + readonly handle: ( + request: Request, + context: Pick, + ) => Effect.Effect; + readonly cleanup: Effect.Effect; + } +>()("@t3tools/task-cache/Cache") {} + +const make = Effect.gen(function* () { + const bindings = yield* Bindings; + const admission = yield* Admission.Admission; + const savings = yield* Savings.Savings; + // The source-only protocol dependency is bundled for Workers, never evaluated by Node during deployment. + const { + authorizeCacheWrite, + authorizeTelemetry, + keys, + cborResponse, + cleanup, + getScope, + selectBlob, + errorResponse, + HttpError, + fetchMetadata, + defaults, + store, + readBody, + Deadline, + Observations, + } = yield* Effect.promise(() => import("./protocol.ts")); + + // The upstream adapter owns the experimental wire format and atomic D1 publication. + const protocol = (run: (signal: AbortSignal) => Promise) => + Effect.tryPromise({ + try: run, + catch: (cause) => + new CacheError({ + status: cause instanceof HttpError ? cause.status : 503, + reason: cause instanceof HttpError ? cause.code : "unavailable", + }), + }); + + const checkLimit = Effect.fn("TaskCache.checkLimit")(function* (operation: string) { + const allowed = yield* protocol(() => + (operation === "store" ? bindings.storeLimit : bindings.readLimit).limit({ + key: `t3code:${operation.startsWith("blob/") ? "blob" : operation}`, + }), + ); + if (!allowed.success) return yield* new CacheError({ status: 429, reason: "rate_limit" }); + }); + + const handle = Effect.fn("TaskCache.handle")( + function* (request: Request, context: Pick) { + const url = new URL(request.url); + const route = /^\/projects\/t3code\/(fetch|store|telemetry|blob\/([0-9a-f-]{36}))$/.exec( + url.pathname, + ); + const operation = route?.[1] ?? "unknown"; + const writing = operation === "store"; + yield* Effect.annotateCurrentSpan({ + "cache.operation": operation.startsWith("blob/") ? "blob" : operation, + }); + if ( + !route || + url.search || + request.method !== (operation.startsWith("blob/") ? "GET" : "POST") + ) { + return yield* new CacheError({ status: 404, reason: "unknown_route" }); + } + if (!request.headers.has("authorization")) + return yield* new CacheError({ status: 401, reason: "missing_identity" }); + const developer = yield* developerIdentity(request, bindings.developerKeys); + if (writing || operation === "telemetry") { + if (developer === null) + return yield* new CacheError({ status: 403, reason: "invalid_identity" }); + } else { + const reader = developer ?? (yield* developerIdentity(request, bindings.readerKeys)); + if (typeof reader !== "string") + return yield* new CacheError({ status: 403, reason: "read_denied" }); + } + return yield* admission + .withPermit( + writing, + Effect.scoped( + Effect.gen(function* () { + const controller = yield* Effect.acquireRelease( + Effect.sync(() => new AbortController()), + (value) => Effect.sync(() => value.abort()), + ); + const deadline = yield* Effect.acquireRelease( + Effect.sync( + () => + new Deadline( + writing ? defaults.deadlineMs : 15000, + AbortSignal.any([request.signal, controller.signal]), + ), + ), + (value) => + Effect.sync(() => { + controller.abort(); + value.dispose(); + }), + ); + const stats = new Observations(); + yield* Effect.addFinalizer(() => + Effect.annotateCurrentSpan( + Object.fromEntries( + Object.entries(stats.toJSON()).map(([key, value]) => [`cache.${key}`, value]), + ), + ), + ); + if (!writing && operation !== "telemetry") yield* checkLimit(operation); + const scope = yield* protocol(() => getScope(bindings.INDEX, "t3code", stats)); + if (operation === "telemetry") { + let identity = developer ?? undefined; + if (identity === undefined) { + identity = yield* protocol(() => authorizeTelemetry(request, scope, keys)); + } + yield* checkLimit(operation); + const bytes = yield* protocol(() => readBody(request.body, 256 * 1024, deadline)); + const summary = yield* decodeSummary(new TextDecoder().decode(bytes)).pipe( + Effect.mapError(() => new CacheError({ status: 400, reason: "invalid_summary" })), + ); + if (summary.tasks.length === 0) return new Response(null, { status: 204 }); + const now = yield* Clock.currentTimeMillis; + if (summary.recordedAt > now + 60_000 || summary.recordedAt < now - 30 * 86400_000) + return yield* new CacheError({ status: 400, reason: "summary_expired" }); + yield* savings + .record(summary, identity) + .pipe( + Effect.mapError( + () => new CacheError({ status: 503, reason: "telemetry_unavailable" }), + ), + ); + yield* recordSummary(summary, identity); + yield* savings + .emitTotals(identity, summary.host) + .pipe( + Effect.mapError( + () => new CacheError({ status: 503, reason: "telemetry_unavailable" }), + ), + ); + return new Response(null, { status: 204 }); + } + if (writing) { + if (developer !== undefined && !scope.writes_enabled) { + return yield* new CacheError({ status: 403, reason: "developer_write_denied" }); + } + const now = yield* Clock.currentTimeMillis; + const identity = + developer === undefined + ? yield* protocol(() => authorizeCacheWrite(request, scope, keys)) + : { + exp: Math.floor(now / 1000) + 900, + repository_id: scope.repository_id, + workflow_ref: `developer/${developer}`, + }; + yield* checkLimit(operation); + return yield* protocol(() => + store(request, bindings, context, scope, identity, defaults, deadline, stats), + ); + } + if (operation === "fetch") { + const result = yield* protocol(() => + fetchMetadata(request, bindings, "t3code", defaults, deadline, stats), + ); + return cborResponse(result); + } + const selected = yield* protocol(() => + selectBlob(bindings.INDEX, "t3code", route[2]!, stats), + ); + if (!selected) return yield* new CacheError({ status: 404, reason: "blob_missing" }); + stats.r2_operations++; + const object = yield* protocol(() => bindings.ARTIFACTS.get(selected.blob_object)); + if (!object) return yield* new CacheError({ status: 404, reason: "blob_missing" }); + if (object.size !== selected.blob_size) + return yield* new CacheError({ status: 503, reason: "blob_size" }); + return new Response(object.body, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(object.size), + "Cache-Control": "no-store", + }, + }); + }), + ), + ) + .pipe( + Effect.timeout(writing ? "2 minutes" : "15 seconds"), + Effect.catchTags({ + TimeoutError: () => Effect.fail(new CacheError({ status: 503, reason: "deadline" })), + }), + ); + }, + Effect.catchTags({ + CacheError: (error) => + Effect.gen(function* () { + if (error.status >= 500) + yield* Effect.logWarning("Cache request failed", { reason: error.reason }); + return errorResponse(new HttpError(error.status, error.reason)); + }), + }), + ); + + return Cache.of({ handle, cleanup: protocol(() => cleanup(bindings)) }); +}); + +export const layer = Layer.effect(Cache, make); diff --git a/infra/task-cache/src/Savings.test.ts b/infra/task-cache/src/Savings.test.ts new file mode 100644 index 000000000000..d61e685150e1 --- /dev/null +++ b/infra/task-cache/src/Savings.test.ts @@ -0,0 +1,71 @@ +// @effect-diagnostics nodeBuiltinImport:off - real SQLite verifies the D1 transaction's deduplication. +import * as NodeSqlite from "node:sqlite"; +import * as NodeFS from "node:fs"; +import * as Effect from "effect/Effect"; +import type * as Cloudflare from "@cloudflare/workers-types"; +import { expect, it } from "@effect/vitest"; +import * as Savings from "./Savings.ts"; +import * as Layer from "effect/Layer"; + +it.effect( + "increments lifetime totals once per receipt, including retries and different hosts", + () => + Effect.gen(function* () { + const sqlite = new NodeSqlite.DatabaseSync(":memory:"); + try { + sqlite.exec( + NodeFS.readFileSync( + new URL("../migrations/savings/0001_savings.sql", import.meta.url), + "utf8", + ), + ); + sqlite.exec( + NodeFS.readFileSync( + new URL("../migrations/savings/0002_unspecified_tier.sql", import.meta.url), + "utf8", + ), + ); + const db = { + prepare: (sql: string) => ({ bind: (...args: (string | number)[]) => ({ sql, args }) }), + batch: async (statements: { sql: string; args: (string | number)[] }[]) => { + sqlite.exec("BEGIN"); + try { + for (const statement of statements) + sqlite.prepare(statement.sql).run(...statement.args); + sqlite.exec("COMMIT"); + } catch (error) { + sqlite.exec("ROLLBACK"); + throw error; + } + return []; + }, + } as unknown as Cloudflare.D1Database; + const savings = yield* Savings.Savings.pipe( + Effect.provide(Savings.layer.pipe(Layer.provide(Layer.succeed(Savings.Database, db)))), + ); + const summary = { + id: "a".repeat(64), + recordedAt: 1, + host: "mbp", + platform: "darwin", + arch: "arm64", + ci: false, + runId: "", + tasks: [{ package: "server", task: "check", source: "remote" as const, savedMs: 900 }], + }; + yield* savings.record(summary, "alice"); + yield* savings.record(summary, "alice"); + yield* savings.record({ ...summary, id: "b".repeat(64), host: "linux" }, "bob"); + yield* savings.record({ ...summary, id: "c".repeat(64) }, "alice"); + const rows = sqlite + .prepare("SELECT developer, host, remote_ms, remote_hits FROM savings ORDER BY developer") + .all(); + expect(rows).toEqual([ + { developer: "alice", host: "mbp", remote_ms: 1800, remote_hits: 2 }, + { developer: "bob", host: "linux", remote_ms: 900, remote_hits: 1 }, + ]); + } finally { + sqlite.close(); + } + }), +); diff --git a/infra/task-cache/src/Savings.ts b/infra/task-cache/src/Savings.ts new file mode 100644 index 000000000000..64723e851c45 --- /dev/null +++ b/infra/task-cache/src/Savings.ts @@ -0,0 +1,140 @@ +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Clock from "effect/Clock"; +import type * as Cloudflare from "@cloudflare/workers-types"; +import type { Summary } from "./Summary.ts"; + +interface Totals { + developer: string; + host: string; + platform: string; + arch: string; + ci: number; + local_ms: number; + unspecified_ms: number; + unspecified_hits: number; + remote_ms: number; + local_hits: number; + remote_hits: number; + executed: number; + restore_failures: number; +} + +export class SavingsError extends Schema.TaggedError()("SavingsError", { + operation: Schema.Literals(["record", "read", "cleanup"]), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to ${this.operation} task-cache savings.`; + } +} + +export class Database extends Context.Service()( + "@t3tools/task-cache/Savings/Database", +) {} + +export class Savings extends Context.Service< + Savings, + { + readonly record: ( + summary: typeof Summary.Type, + developer: string, + ) => Effect.Effect; + readonly emitTotals: (developer?: string, host?: string) => Effect.Effect; + readonly cleanup: Effect.Effect; + } +>()("@t3tools/task-cache/Savings") {} + +const make = Effect.gen(function* () { + const database = yield* Database; + const record = Effect.fn("TaskCache.persistSavings")(function* ( + summary: typeof Summary.Type, + developer: string, + ) { + const local = summary.tasks.filter((task) => task.source === "local"); + const unspecified = summary.tasks.filter((task) => task.source === "unspecified"); + const remote = summary.tasks.filter((task) => task.source === "remote"); + // D1 batch is transactional. changes() gates the increment on the preceding receipt insertion. + yield* Effect.tryPromise({ + try: () => + database.batch([ + database + .prepare("INSERT INTO receipts(id, recorded_at) VALUES (?, ?) ON CONFLICT DO NOTHING") + .bind(summary.id, summary.recordedAt), + database + .prepare(`INSERT INTO savings(developer, host, platform, arch, ci, local_ms, remote_ms, local_hits, remote_hits, executed, restore_failures, unspecified_ms, unspecified_hits) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE changes() = 1 + ON CONFLICT DO UPDATE SET + local_ms = local_ms + excluded.local_ms, remote_ms = remote_ms + excluded.remote_ms, + local_hits = local_hits + excluded.local_hits, remote_hits = remote_hits + excluded.remote_hits, + executed = executed + excluded.executed, restore_failures = restore_failures + excluded.restore_failures, + unspecified_ms = unspecified_ms + excluded.unspecified_ms, unspecified_hits = unspecified_hits + excluded.unspecified_hits`) + .bind( + developer, + summary.host, + summary.platform, + summary.arch, + Number(summary.ci), + local.reduce((sum, task) => sum + task.savedMs, 0), + remote.reduce((sum, task) => sum + task.savedMs, 0), + local.length, + remote.length, + summary.tasks.filter((task) => task.source === "executed").length, + summary.tasks.filter((task) => task.source === "failed").length, + unspecified.reduce((sum, task) => sum + task.savedMs, 0), + unspecified.length, + ), + ]), + catch: (cause) => new SavingsError({ operation: "record", cause }), + }); + }); + + const emitTotals = Effect.fn("TaskCache.emitTotals")(function* ( + developer?: string, + host?: string, + ) { + const rows = yield* Effect.tryPromise({ + try: () => + (developer === undefined + ? database.prepare("SELECT * FROM savings") + : database + .prepare("SELECT * FROM savings WHERE developer = ? AND host = ?") + .bind(developer, host) + ).all(), + catch: (cause) => new SavingsError({ operation: "read", cause }), + }); + yield* Effect.forEach(rows.results, (row) => + Effect.annotateCurrentSpan({ + "developer.id": row.developer, + "host.name": row.host, + "host.os": row.platform, + "host.arch": row.arch, + ci: Boolean(row.ci), + "cache.local_saved_ms": row.local_ms, + "cache.unspecified_saved_ms": row.unspecified_ms, + "cache.unspecified_hits": row.unspecified_hits, + "cache.remote_saved_ms": row.remote_ms, + "cache.local_hits": row.local_hits, + "cache.remote_hits": row.remote_hits, + "cache.executed": row.executed, + "cache.restore_failures": row.restore_failures, + }).pipe(Effect.withSpan("TaskCache.lifetime")), + ); + }); + + const cleanup = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* Effect.tryPromise({ + try: () => + database + .prepare("DELETE FROM receipts WHERE recorded_at < ?") + .bind(now - 31 * 86400_000) + .run(), + catch: (cause) => new SavingsError({ operation: "cleanup", cause }), + }); + }); + return Savings.of({ record, emitTotals, cleanup }); +}); + +export const layer = Layer.effect(Savings, make); diff --git a/infra/task-cache/src/Summary.ts b/infra/task-cache/src/Summary.ts new file mode 100644 index 000000000000..320d6fd43a2b --- /dev/null +++ b/infra/task-cache/src/Summary.ts @@ -0,0 +1,64 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const Label = Schema.String.check(Schema.isMaxLength(256)); +const Count = Schema.Number.check( + Schema.isInt(), + Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), +); +export const Summary = Schema.Struct({ + id: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + recordedAt: Count, + host: Label, + platform: Label, + arch: Label, + ci: Schema.Boolean, + runId: Label, + tasks: Schema.Array( + Schema.Struct({ + package: Label, + task: Label, + source: Schema.Literals(["local", "remote", "unspecified", "executed", "failed"]), + savedMs: Count, + }), + ).check(Schema.isMaxLength(1000)), +}); + +export const recordSummary = Effect.fn("TaskCache.summary")(function* ( + summary: typeof Summary.Type, + developer: string, +) { + const local = summary.tasks.filter((task) => task.source === "local"); + const unspecified = summary.tasks.filter((task) => task.source === "unspecified"); + const remote = summary.tasks.filter((task) => task.source === "remote"); + yield* Effect.annotateCurrentSpan({ + "cache.event_id": summary.id, + "cache.recorded_at": summary.recordedAt, + "developer.id": developer, + "host.name": summary.host, + "host.os": summary.platform, + "host.arch": summary.arch, + ci: summary.ci, + "ci.run_id": summary.runId, + "cache.local_hits": local.length, + "cache.unspecified_hits": unspecified.length, + "cache.unspecified_saved_ms": unspecified.reduce((sum, task) => sum + task.savedMs, 0), + "cache.remote_hits": remote.length, + "cache.executed": summary.tasks.filter((task) => task.source === "executed").length, + "cache.restore_failures": summary.tasks.filter((task) => task.source === "failed").length, + "cache.local_saved_ms": local.reduce((sum, task) => sum + task.savedMs, 0), + "cache.remote_saved_ms": remote.reduce((sum, task) => sum + task.savedMs, 0), + }); + yield* Effect.forEach(summary.tasks, (task, index) => + Effect.annotateCurrentSpan({ + "cache.event_id": summary.id, + "cache.task_index": index, + "developer.id": developer, + "host.name": summary.host, + "task.package": task.package, + "task.name": task.task, + "cache.source": task.source, + "cache.saved_ms": task.savedMs, + }).pipe(Effect.withSpan("TaskCache.task")), + ); +}); diff --git a/infra/task-cache/src/WriterRegistry.test.ts b/infra/task-cache/src/WriterRegistry.test.ts new file mode 100644 index 000000000000..449bdf5b1327 --- /dev/null +++ b/infra/task-cache/src/WriterRegistry.test.ts @@ -0,0 +1,247 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as WriterRegistry from "./WriterRegistry.ts"; +import { WriterSecrets } from "./deploymentConfig.ts"; + +const readKeys = Config.Redacted("DEVELOPER_KEYS").pipe(Effect.provide(WriterSecrets)); + +it.effect("loads the private writer registry as deployment configuration", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(`${home}/.config/t3-task-cache`, { recursive: true }); + yield* fs.writeFileString(`${home}/.config/t3-task-cache/writers.json`, '{"maria":"digest"}'); + yield* fs.writeFileString( + `${home}/.config/t3-task-cache/readers.json`, + '{"ci-reader":"digest"}', + ); + const keys = yield* readKeys.pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + ); + expect(JSON.parse(Redacted.value(keys))).toEqual({ maria: "digest" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("uses explicit deployment credentials without requiring a local registry", () => + Effect.gen(function* () { + const keys = yield* readKeys.pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ DEVELOPER_KEYS: '{"theo":"digest"}', READER_KEYS: "{}" }), + ), + ), + ); + expect(JSON.parse(Redacted.value(keys))).toEqual({ theo: "digest" }); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("fails instead of deploying an empty registry when credentials are missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const result = yield* readKeys.pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("rejects reserved writer names without modifying credential stores", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const home = yield* fs.makeTempDirectoryScoped(); + const state = `${home}/.config/t3-task-cache`; + yield* fs.makeDirectory(state, { recursive: true }); + const writers = '{"maria":"digest"}'; + const credentials = '{"https://cache.example":{"name":"maria","token":"secret"}}'; + yield* fs.writeFileString(`${state}/writers.json`, writers); + yield* fs.writeFileString(`${state}/credentials.json`, credentials); + for (const name of ["writers", "credentials", "readers", "ci-reader"]) { + const code = yield* spawner.exitCode( + ChildProcess.make( + "node", + [`${import.meta.dirname}/../scripts/manage.ts`, "add-writer", name], + { + env: { HOME: home }, + extendEnv: true, + stdout: "ignore", + stderr: "ignore", + }, + ), + ); + expect(code).not.toBe(0); + expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(writers); + expect(yield* fs.readFileString(`${state}/credentials.json`)).toBe(credentials); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("writes credentials for the supplied deployed worker endpoint", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const endpoint = "https://preview-cache.example/projects/t3code"; + const file = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("maria", false, endpoint, true)), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + ), + ), + ); + expect(file).toBe(`${home}/.config/t3-task-cache/maria.json`); + if (!file) return yield* Effect.die("Expected a credential file"); + const credential = JSON.parse(yield* fs.readFileString(file)); + expect(credential.endpoint).toBe(endpoint); + expect(credential.name).toBe("maria"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refuses a concurrent registry update without changing committed credentials", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const state = `${home}/.config/t3-task-cache`; + yield* fs.makeDirectory(`${state}/writers.lock`, { recursive: true }); + yield* fs.writeFileString(`${state}/writers.json`, '{"maria":"digest"}'); + const result = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("theo", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* fs.readFileString(`${state}/writers.json`)).toBe('{"maria":"digest"}'); + expect(yield* fs.exists(`${state}/theo.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps the committed registry intact if a replacement write fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const state = `${home}/.config/t3-task-cache`; + yield* fs.makeDirectory(state, { recursive: true }); + const original = '{"maria":"digest"}'; + yield* fs.writeFileString(`${state}/writers.json`, original); + const result = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("theo", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide([ + ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home })), + Layer.succeed(FileSystem.FileSystem, { + ...fs, + writeFileString: (path) => + fs + .writeFileString(path, "partial") + .pipe(Effect.andThen(Effect.die("disk write failed"))), + }), + ]), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(original); + expect(yield* fs.exists(`${state}/writers.lock`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("preserves filesystem errors when acquiring the registry lock", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const denied = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + }); + const error = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("theo", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide([ + ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home })), + Layer.succeed(FileSystem.FileSystem, { + ...fs, + makeDirectory: (path, options) => + path.endsWith("writers.lock") + ? Effect.fail(denied) + : fs.makeDirectory(path, options), + }), + ]), + ), + ), + Effect.flip, + ); + expect(error).toBe(denied); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refuses to initialize a missing writer registry implicitly", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const result = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("theo", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* fs.exists(`${home}/.config/t3-task-cache/writers.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps existing authorization if credential publication fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const state = `${home}/.config/t3-task-cache`; + yield* fs.makeDirectory(state, { recursive: true }); + const original = '{"maria":"old-digest"}'; + yield* fs.writeFileString(`${state}/writers.json`, original); + const error = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + }); + const result = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("maria", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide([ + ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home })), + Layer.succeed(FileSystem.FileSystem, { + ...fs, + rename: (from, to) => + to === `${state}/maria.json` ? Effect.fail(error) : fs.rename(from, to), + }), + ]), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(original); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/infra/task-cache/src/WriterRegistry.ts b/infra/task-cache/src/WriterRegistry.ts new file mode 100644 index 000000000000..0032f66e292d --- /dev/null +++ b/infra/task-cache/src/WriterRegistry.ts @@ -0,0 +1,128 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import type * as PlatformError from "effect/PlatformError"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +const Writers = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); +const decodeWriters = Schema.decodeEffect(Writers); +const encodeWriters = Schema.encodeEffect(Writers); + +const stateDirectory = Config.String("HOME").pipe( + Effect.map((home) => `${home}/.config/t3-task-cache`), +); + +const Credential = Schema.fromJsonString( + Schema.Struct({ endpoint: Schema.String, name: Schema.String, token: Schema.String }), +); +class ManagementError extends Schema.TaggedError()("ManagementError", { + message: Schema.String, +}) {} +export const isWriterName = (name: string) => + /^(?!writers$|credentials$|readers$|ci-reader$)[a-z0-9-]{1,64}$/.test(name); + +const encodeCredential = Schema.encodeEffect(Credential); + +export class WriterRegistry extends Context.Service< + WriterRegistry, + { + readonly read: () => Effect.Effect< + Record, + PlatformError.PlatformError | Schema.SchemaError + >; + readonly update: ( + name: string, + revoke: boolean, + endpoint?: string, + initialize?: boolean, + ) => Effect.Effect< + string | undefined, + PlatformError.PlatformError | Schema.SchemaError | ManagementError + >; + } +>()("@t3tools/task-cache/WriterRegistry") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const state = yield* stateDirectory; + const readWriters = Effect.fn("TaskCache.readWriters")(function* () { + return yield* decodeWriters(yield* fs.readFileString(`${state}/writers.json`)); + }); + + const save = Effect.fn("TaskCache.save")(function* ( + entries: ReadonlyArray, + ) { + yield* fs.makeDirectory(state, { recursive: true, mode: 0o700 }); + yield* Effect.scoped( + Effect.gen(function* () { + const directory = yield* fs.makeTempDirectoryScoped({ + directory: state, + prefix: ".writer-", + }); + for (const [name, value] of entries) + yield* fs.writeFileString(`${directory}/${name}.json`, value + "\n", { mode: 0o600 }); + // The registry is the authorization commit point and must publish last. + for (const [name] of entries) + yield* fs.rename(`${directory}/${name}.json`, `${state}/${name}.json`); + }), + ); + }); + + const updateWriter = Effect.fn("TaskCache.updateWriter")(function* ( + name: string, + revoke: boolean, + endpoint?: string, + initialize = false, + ) { + if (!isWriterName(name)) return yield* new ManagementError({ message: "Invalid writer name." }); + if (!revoke && !endpoint) + return yield* new ManagementError({ message: "A deployed cache endpoint is required." }); + return yield* Effect.scoped( + Effect.gen(function* () { + yield* fs.makeDirectory(state, { recursive: true, mode: 0o700 }); + // Exclusive creation coordinates separate management processes as well as fibers. + yield* Effect.acquireRelease( + fs.makeDirectory(`${state}/writers.lock`).pipe( + Effect.mapError((error) => + error.reason._tag === "AlreadyExists" + ? new ManagementError({ + message: + "Writer registry is locked by another update. Retry after it completes; remove writers.lock only if that process has stopped.", + }) + : error, + ), + ), + () => fs.remove(`${state}/writers.lock`, { recursive: true }).pipe(Effect.orDie), + ); + const exists = yield* fs.exists(`${state}/writers.json`); + if (!exists && !initialize) + return yield* new ManagementError({ + message: + "Writer registry is missing. Copy the existing private registry from its administrator; use --new-registry only when creating a new cache.", + }); + const writers = { ...(exists || revoke ? yield* readWriters() : {}) }; + const token = hex(yield* crypto.randomBytes(32)); + if (revoke) delete writers[name]; + else { + writers[name] = hex(yield* crypto.digest("SHA-256", new TextEncoder().encode(token))); + } + const entries: Array = []; + if (!revoke && endpoint) + entries.push([name, yield* encodeCredential({ endpoint, name, token })]); + entries.push(["writers", yield* encodeWriters(writers)]); + yield* save(entries); + return revoke ? undefined : `${state}/${name}.json`; + }), + ); + }); + return WriterRegistry.of({ read: readWriters, update: updateWriter }); +}); + +export const layer = Layer.effect(WriterRegistry, make); + +const hex = (bytes: Uint8Array) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); diff --git a/infra/task-cache/src/deploymentConfig.ts b/infra/task-cache/src/deploymentConfig.ts new file mode 100644 index 000000000000..0377c0c34d1a --- /dev/null +++ b/infra/task-cache/src/deploymentConfig.ts @@ -0,0 +1,39 @@ +import * as Layer from "effect/Layer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as Schema from "effect/Schema"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as WriterRegistry from "./WriterRegistry.ts"; + +const encodeWriters = Schema.encodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), +); + +// An absent registry must fail deployment rather than revoke every existing writer. +export const WriterSecrets = ConfigProvider.layerAdd( + Effect.gen(function* () { + const configured = yield* Config.Redacted("DEVELOPER_KEYS").pipe(Config.option); + const readers = yield* Config.Redacted("READER_KEYS").pipe(Config.option); + const fs = yield* FileSystem.FileSystem; + const readerKeys = Option.isSome(readers) + ? {} + : { + READER_KEYS: yield* fs.readFileString( + `${yield* Config.String("HOME")}/.config/t3-task-cache/readers.json`, + ), + }; + if (Option.isSome(configured)) return ConfigProvider.fromUnknown(readerKeys); + return ConfigProvider.fromUnknown({ + ...readerKeys, + DEVELOPER_KEYS: yield* encodeWriters( + yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.read()), + Effect.provide(WriterRegistry.layer.pipe(Layer.provide(NodeCrypto.layer))), + ), + ), + }); + }), +); diff --git a/infra/task-cache/src/developerAuth.test.ts b/infra/task-cache/src/developerAuth.test.ts new file mode 100644 index 000000000000..1f28a1ad13ab --- /dev/null +++ b/infra/task-cache/src/developerAuth.test.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import { expect, it } from "@effect/vitest"; + +import { developerIdentity } from "./developerAuth.ts"; + +const token = "fixture-token-012345678901234567890123456789"; +const keys = JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", +}); +const request = (credentials: string) => + new Request("https://cache.example/projects/t3code/store", { + headers: { authorization: `Basic ${btoa(credentials)}` }, + }); + +it.effect( + "accepts the registered developer and rejects wrong, revoked, or malformed credentials", + () => + Effect.gen(function* () { + expect(yield* developerIdentity(request(`julius:${token}`), keys)).toBe("julius"); + expect(yield* developerIdentity(request(`theo:${token}`), keys)).toBeNull(); + expect(yield* developerIdentity(request(`julius:${token}wrong`), keys)).toBeNull(); + expect(yield* developerIdentity(request(`julius:${token}`), "{}")).toBeNull(); + expect(yield* developerIdentity(request(`julius:${token}`), undefined)).toBeNull(); + expect(yield* developerIdentity(request("malformed"), keys)).toBeNull(); + expect(yield* developerIdentity(request(`constructor:${token}`), keys)).toBeNull(); + }), +); + +it.effect("leaves anonymous reads and GitHub OIDC authorization to the upstream Worker", () => + Effect.gen(function* () { + expect(yield* developerIdentity(new Request("https://cache.example"), keys)).toBeUndefined(); + const oidc = new Request("https://cache.example", { + headers: { authorization: "Bearer github-oidc-token" }, + }); + expect(yield* developerIdentity(oidc, keys)).toBeUndefined(); + }), +); diff --git a/infra/task-cache/src/developerAuth.ts b/infra/task-cache/src/developerAuth.ts new file mode 100644 index 000000000000..f358a8961e2f --- /dev/null +++ b/infra/task-cache/src/developerAuth.ts @@ -0,0 +1,35 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const Keys = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); + +const decodeKeys = Schema.decodeEffect(Keys); + +export const developerIdentity = Effect.fn("TaskCache.developerIdentity")(function* ( + request: Request, + encodedKeys: string | undefined, +) { + const authorization = request.headers.get("authorization"); + if (!authorization?.toLowerCase().startsWith("basic ")) return undefined; + if (!encodedKeys || authorization.length > 1024) return null; + + const decoded = yield* Effect.try(() => atob(authorization.slice(6))).pipe(Effect.option); + if (decoded._tag === "None") return null; + const keys = yield* decodeKeys(encodedKeys).pipe(Effect.option); + if (keys._tag === "None") return null; + const separator = decoded.value.indexOf(":"); + const name = decoded.value.slice(0, separator); + const token = decoded.value.slice(separator + 1); + if (separator < 1 || !/^[a-z0-9-]{1,64}$/.test(name) || token.length < 32) return null; + const expected = Object.hasOwn(keys.value, name) ? keys.value[name] : undefined; + if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) return null; + const digest = new Uint8Array( + yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))), + ); + const actual = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + let difference = 0; + for (let index = 0; index < actual.length; index++) { + difference |= actual.charCodeAt(index) ^ expected.charCodeAt(index); + } + return difference === 0 ? name : null; +}); diff --git a/infra/task-cache/src/flush.ts b/infra/task-cache/src/flush.ts new file mode 100644 index 000000000000..aef97bb126de --- /dev/null +++ b/infra/task-cache/src/flush.ts @@ -0,0 +1,3 @@ +import { flushTelemetry } from "./telemetry.ts"; + +await flushTelemetry(); diff --git a/infra/task-cache/src/nativeHook.js b/infra/task-cache/src/nativeHook.js new file mode 100644 index 000000000000..12eac748eb01 --- /dev/null +++ b/infra/task-cache/src/nativeHook.js @@ -0,0 +1,17 @@ +/** @param {string} source */ +export function patchNativeCli(source) { + if (source.includes('import { startRunHook } from "./run-hook.js";')) return source; + const start = "\tconst exitCode = await run({"; + const end = "\t\targs: rustCliArgs\n\t});"; + if (source.split(start).length !== 2 || source.split(end).length !== 2) + throw new Error("Unsupported Vite+ entrypoint; no files were changed."); + return ( + 'import { startRunHook } from "./run-hook.js";\n' + + source + .replace( + start, + '\tconst finishRunHook = await startRunHook(command, process.cwd(), async () => (await (await import("../binding/index.js")).detectWorkspace(process.cwd())).root);\n\tif (finishRunHook?.remoteCache && !rustCliArgs.includes("--last-details") && !rustCliArgs.some((arg) => arg === "--remote-cache" || arg.startsWith("--remote-cache="))) { rustCliArgs.splice(1, 0, "--remote-cache", finishRunHook.remoteCache); process.env.VP_T3_TASK_CACHE_MODE = finishRunHook.remoteCache; }\n\tlet exitCode;\n\ttry {\n\texitCode = await run({', + ) + .replace(end, end + "\n\t} finally {\n\t\tawait finishRunHook?.();\n\t}") + ); +} diff --git a/infra/task-cache/src/nativeHook.test.ts b/infra/task-cache/src/nativeHook.test.ts new file mode 100644 index 000000000000..de8741a9206a --- /dev/null +++ b/infra/task-cache/src/nativeHook.test.ts @@ -0,0 +1,86 @@ +import { expect, it } from "vite-plus/test"; +import { patchNativeCli } from "./nativeHook.js"; + +it("wraps the native runner in a finally hook and patches only once", () => { + const original = + "\tconst exitCode = await run({\n\t\targs: rustCliArgs\n\t});\n\tprocess.exit(exitCode);"; + const patched = patchNativeCli(original); + expect(patched).toContain("await startRunHook(command,"); + expect(patched).toContain("finally {\n\t\tawait finishRunHook?.();"); + expect(patched).toContain("process.exit(exitCode)"); + expect(patchNativeCli(patched)).toBe(patched); +}); + +it("rejects an unknown native entrypoint before changing it", () => { + expect(() => patchNativeCli("changed upstream entrypoint")).toThrow( + "Unsupported Vite+ entrypoint", + ); +}); + +it("leaves an unsupported CLI runnable when the machine preload is enabled", async () => { + const fs = await import("node:fs/promises"); + const os = await import("node:os"); + const path = await import("node:path"); + const { promisify } = await import("node:util"); + const { execFile } = await import("node:child_process"); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cache-preload-")); + try { + const directory = path.join(root, "vite-plus/dist"); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(root, "package.json"), '{"type":"module"}'); + await fs.writeFile(path.join(directory, "bin.js"), 'console.log("unmodified CLI");'); + const result = await promisify(execFile)( + process.execPath, + ["--import", new URL("./preload.mjs", import.meta.url).href, path.join(directory, "bin.js")], + { env: { ...process.env, NODE_OPTIONS: "" } }, + ); + expect(result.stdout.trim()).toBe("unmodified CLI"); + expect(result.stderr).toBe(""); + await fs.mkdir(path.join(root, "vite-plus/binding")); + await fs.writeFile(path.join(root, "vite-plus/binding/index.js"), "export {};"); + await fs.writeFile( + path.join(directory, "bin.js"), + 'const command="--version"; const rustCliArgs=[]; const run=async()=>0;\n\tconst exitCode = await run({\n\t\targs: rustCliArgs\n\t});\nconsole.log("compatible CLI");', + ); + const compatible = await promisify(execFile)( + process.execPath, + ["--import", new URL("./preload.mjs", import.meta.url).href, path.join(directory, "bin.js")], + { env: { ...process.env, NODE_OPTIONS: "" } }, + ); + expect(compatible.stdout.trim()).toBe("compatible CLI"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +it("does not execute hook modules supplied by ancestor package manifests", async () => { + const fs = await import("node:fs/promises"); + const os = await import("node:os"); + const path = await import("node:path"); + const { promisify } = await import("node:util"); + const { execFile } = await import("node:child_process"); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cache-ancestor-")); + try { + await fs.mkdir(path.join(root, "project")); + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ vitePlus: { runHook: "./malicious.mjs" } }), + ); + await fs.writeFile( + path.join(root, "malicious.mjs"), + 'throw new Error("ancestor module executed")', + ); + const result = await promisify(execFile)( + process.execPath, + [ + "--input-type=module", + "-e", + `import { startRunHook } from ${JSON.stringify(new URL("./nativeRunHook.js", import.meta.url).href)}; await startRunHook("run", ${JSON.stringify(path.join(root, "project"))});`, + ], + { env: { ...process.env, HOME: root, NODE_OPTIONS: "" } }, + ); + expect(result.stderr).toBe(""); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/infra/task-cache/src/nativeRunHook.js b/infra/task-cache/src/nativeRunHook.js new file mode 100644 index 000000000000..0d75ebcc2b7e --- /dev/null +++ b/infra/task-cache/src/nativeRunHook.js @@ -0,0 +1,65 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +async function readJson(file) { + try { + return JSON.parse(await NodeFSP.readFile(file, "utf8")); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +// Optional repository integration; never changes the task runner's exit status. +export async function startRunHook(command, cwd = process.cwd(), resolveWorkspaceRoot) { + if (command !== "run") return; + try { + const configured = await readJson( + NodePath.join(NodeOS.homedir(), ".config/vite-plus/run-hook.json"), + ); + let root = cwd; + while (true) { + let hook; + if (configured?.rootMarker && configured?.module) { + try { + await NodeFSP.access(NodePath.join(root, configured.rootMarker)); + hook = configured.module; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + if (typeof hook === "string") { + // Use Vite's own workspace resolver so nested standalone projects stay independent. + if (resolveWorkspaceRoot) { + const workspace = await resolveWorkspaceRoot(); + if (!workspace || (await NodeFSP.realpath(workspace)) !== (await NodeFSP.realpath(root))) + break; + } + const integration = await import(NodeURL.pathToFileURL(NodePath.resolve(root, hook)).href); + const finish = await integration.default({ root }); + return Object.assign( + async () => { + try { + await finish?.(); + } catch { + console.warn("[Vite+] Run hook failed to finish."); + } + }, + { remoteCache: finish?.remoteCache }, + ); + } + const parent = NodePath.dirname(root); + if (parent === root) break; + root = parent; + } + // Vite forwards our CLI mode to tasks, including agents working in other projects. + if ( + process.env.VP_T3_TASK_CACHE_MODE && + process.env.VP_REMOTE_CACHE === process.env.VP_T3_TASK_CACHE_MODE + ) + delete process.env.VP_REMOTE_CACHE; + } catch { + console.warn("[Vite+] Run hook failed to start."); + } +} diff --git a/infra/task-cache/src/observability.ts b/infra/task-cache/src/observability.ts new file mode 100644 index 000000000000..fa5b96c20170 --- /dev/null +++ b/infra/task-cache/src/observability.ts @@ -0,0 +1,203 @@ +import * as Axiom from "alchemy/Axiom"; +import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; +import { cacheName } from "./resources.ts"; + +// Native summaries may be observed by nested launchers and retried after a lost response. +// Deduplicate before summing; task runtime is a compute proxy, not measured CPU or critical-path time. +export const savingsQuery = ( + dataset: string, + group?: "developer" | "host" | "ci", + lifetime = false, +) => + [ + `['${dataset}']`, + `| where name == '${lifetime ? "TaskCache.lifetime" : "TaskCache.summary"}'`, + `| extend a = ['attributes.custom']`, + `| extend event_id = tostring(a['cache.event_id']), developer = tostring(a['developer.id']), host = tostring(['attributes.host.name']), ci = tobool(a['ci']), recorded_at = tolong(a['cache.recorded_at']), unspecified_ms = coalesce(tolong(a['cache.unspecified_saved_ms']), 0), unspecified_hits = coalesce(tolong(a['cache.unspecified_hits']), 0), local_ms = tolong(a['cache.local_saved_ms']), remote_ms = tolong(a['cache.remote_saved_ms']), local_hits = tolong(a['cache.local_hits']), remote_hits = tolong(a['cache.remote_hits']), executed = tolong(a['cache.executed']), restore_failures = tolong(a['cache.restore_failures'])`, + `| summarize arg_max(_time, unspecified_ms, unspecified_hits, local_ms, remote_ms, local_hits, remote_hits, executed, restore_failures${lifetime ? "" : ", developer, host, ci, recorded_at"}) by ${lifetime ? "developer, host, ci, platform = tostring(a['host.os']), arch = tostring(['attributes.host.arch'])" : "event_id"}`, + ...(!lifetime ? ["| extend _time = unixtime_milliseconds_todatetime(recorded_at)"] : []), + `| where local_hits + remote_hits + unspecified_hits + executed + restore_failures > 0`, + `| summarize saved_task_hours = sum(local_ms + remote_ms + unspecified_ms) / 3600000.0, remote_saved_hours = sum(remote_ms) / 3600000.0, local_saved_hours = sum(local_ms) / 3600000.0, unspecified_saved_hours = sum(unspecified_ms) / 3600000.0, unspecified_hits = sum(unspecified_hits), local_hits = sum(local_hits), remote_hits = sum(remote_hits), executed = sum(executed), restore_failures = sum(restore_failures)${group ? ` by ${group}` : ""}`, + ].join("\n"); + +const summaryRows = (dataset: string) => savingsQuery(dataset).split("\n").slice(0, -1).join("\n"); + +export const Observability = Effect.gen(function* () { + const name = yield* cacheName; + const traces = yield* Axiom.Dataset("CacheTraces", { + name: `${name}-traces`, + kind: "otel:traces:v1", + description: "Shared task cache: service traces and unsampled native task savings reports.", + retentionDays: 30, + useRetentionPeriod: true, + }); + const token = yield* Axiom.ApiToken("CacheIngestToken", { + name: `${name}-otel-ingest`, + datasetCapabilities: Output.map(traces.name, (dataset) => ({ + [dataset]: { ingest: ["create" as const] }, + })), + }); + const charts = [ + { + id: "total", + name: "⏳ Task hours given back · lifetime", + type: "Statistic", + x: 0, + y: 0, + w: 3, + h: 3, + query: (dataset: string) => + `${savingsQuery(dataset, undefined, true)}\n| project saved_task_hours`, + }, + { + id: "remote", + name: "🌍 Remote hours shared · lifetime", + type: "Statistic", + x: 3, + y: 0, + w: 3, + h: 3, + query: (dataset: string) => + `${savingsQuery(dataset, undefined, true)}\n| project remote_saved_hours`, + }, + { + id: "hit-rate", + name: "🎯 Cache hit rate · lifetime (%)", + type: "Statistic", + x: 6, + y: 0, + w: 3, + h: 3, + query: (dataset: string) => + `${savingsQuery(dataset, undefined, true)}\n| project hit_rate = round(100.0 * (local_hits + remote_hits + unspecified_hits) / (local_hits + remote_hits + unspecified_hits + executed), 1)`, + }, + { + id: "skipped", + name: "⚡ Tasks we skipped · lifetime", + type: "Statistic", + x: 9, + y: 0, + w: 3, + h: 3, + query: (dataset: string) => + `${savingsQuery(dataset, undefined, true)}\n| project cached_tasks = local_hits + remote_hits + unspecified_hits`, + }, + { + id: "savings-trend", + name: "📈 Minutes given back over time", + type: "TimeSeries", + x: 0, + y: 3, + w: 8, + h: 5, + query: (dataset: string) => + `${summaryRows(dataset)}\n| summarize local_minutes = sum(local_ms) / 60000.0, remote_minutes = sum(remote_ms) / 60000.0, unspecified_minutes = sum(unspecified_ms) / 60000.0 by bin_auto(_time)`, + }, + { + id: "developer", + name: "🏆 Who is saving the most?", + type: "Pie", + x: 8, + y: 3, + w: 4, + h: 5, + query: (dataset: string) => + `${savingsQuery(dataset, "developer", true)}\n| project developer, saved_task_hours\n| order by saved_task_hours desc`, + }, + { + id: "tasks-trend", + name: "🔥 Cache hits vs actual execution", + type: "TimeSeries", + x: 0, + y: 8, + w: 8, + h: 5, + query: (dataset: string) => + `${summaryRows(dataset)}\n| summarize local_hits = sum(local_hits), remote_hits = sum(remote_hits), unspecified_hits = sum(unspecified_hits), executed = sum(executed) by bin_auto(_time)`, + }, + { + id: "host", + name: "🖥️ The machine leaderboard", + type: "Pie", + x: 8, + y: 8, + w: 4, + h: 5, + query: (dataset: string) => + `${savingsQuery(dataset, "host", true)}\n| project host, saved_task_hours\n| order by saved_task_hours desc`, + }, + { + id: "leaderboard", + name: "🏅 Developer scorecard · lifetime", + type: "Table", + x: 0, + y: 13, + w: 8, + h: 4, + query: (dataset: string) => + `${savingsQuery(dataset, "developer", true)}\n| project developer, saved_task_hours, remote_saved_hours, cached_tasks = local_hits + remote_hits + unspecified_hits, executed\n| order by saved_task_hours desc`, + }, + { + id: "ci", + name: "🤖 CI vs developer machines · hours saved", + type: "Pie", + x: 8, + y: 13, + w: 4, + h: 4, + query: (dataset: string) => + `${savingsQuery(dataset, "ci", true)}\n| summarize saved_task_hours = sum(saved_task_hours) by environment = iff(ci, "CI", "Developer machines")`, + }, + ] as const; + for (const chart of charts) { + yield* Axiom.View(`CacheSavings-${chart.id}`, { + name: `${name}-${chart.id}-savings`, + description: chart.name, + datasets: [traces.name], + aplQuery: Output.map(traces.name, chart.query), + }); + } + const dashboard = yield* Axiom.Dashboard("CacheSavingsDashboard", { + dashboard: { + name: "⚡ Task Cache · Time Back", + owner: "", + schemaVersion: 2, + refreshTime: 60, + description: + "Avoided task execution time, not CPU seconds or elapsed developer time. Local and remote hits; deduplicated native Vite+ summaries. Lifetime counters are retained in D1 and refreshed hourly. Raw traces have 30-day retention.", + timeWindowStart: "qr-now-30d", + timeWindowEnd: "qr-now", + charts: charts.map((chart) => { + const common = { + id: chart.id, + name: chart.name, + query: { apl: Output.map(traces.name, chart.query) }, + }; + switch (chart.type) { + case "Statistic": + return { ...common, type: "Statistic" as const }; + case "TimeSeries": + return { + ...common, + type: "TimeSeries" as const, + query: { + ...common.query, + queryOptions: { + timeSeriesVariant: chart.id === "savings-trend" ? "area" : "bars", + displayNull: "zero", + overlayCharts: "true", + }, + }, + }; + case "Pie": + return { ...common, type: "Pie" as const }; + case "Table": + return { ...common, type: "Table" as const }; + } + }), + layout: charts.map(({ id, x, y, w, h }) => ({ i: id, x, y, w, h })), + }, + }); + return { traces, token, dashboard }; +}); diff --git a/infra/task-cache/src/preload.mjs b/infra/task-cache/src/preload.mjs new file mode 100644 index 000000000000..a76bfe2069c9 --- /dev/null +++ b/infra/task-cache/src/preload.mjs @@ -0,0 +1,42 @@ +import * as NodeModule from "node:module"; + +// Vite forwards explicit modes to tasks; clear our inherited default even on older Node. +if ( + process.env.VP_T3_TASK_CACHE_MODE && + process.env.VP_REMOTE_CACHE === process.env.VP_T3_TASK_CACHE_MODE +) + delete process.env.VP_REMOTE_CACHE; + +// Older Node processes in unrelated projects must remain unaffected by machine setup. +if ( + typeof NodeModule.registerHooks === "function" && + Number(process.versions.node.split(".")[0]) >= 24 +) { + try { + const { patchNativeCli } = await import("./nativeHook.js"); + const hookUrl = new URL("./nativeRunHook.js", import.meta.url).href; + NodeModule.registerHooks({ + load(url, context, nextLoad) { + const loaded = nextLoad(url, context); + if (!url.endsWith("/vite-plus/dist/bin.js")) return loaded; + try { + const source = + typeof loaded.source === "string" + ? loaded.source + : Buffer.from(loaded.source).toString(); + return { + ...loaded, + source: patchNativeCli(source).replace( + 'from "./run-hook.js"', + `from ${JSON.stringify(hookUrl)}`, + ), + }; + } catch { + return loaded; + } + }, + }); + } catch { + // Restricted Node processes must still run when the optional hook cannot load. + } +} diff --git a/infra/task-cache/src/protocol.ts b/infra/task-cache/src/protocol.ts new file mode 100644 index 000000000000..0f8cf83ddc9a --- /dev/null +++ b/infra/task-cache/src/protocol.ts @@ -0,0 +1,21 @@ +import { githubKeys } from "../vendor/vite-plus-remote-cache-cloudflare/src/auth.ts"; + +export { cborResponse } from "../vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts"; +export { + cleanup, + getScope, + selectBlob, +} from "../vendor/vite-plus-remote-cache-cloudflare/src/database.ts"; +export { + errorResponse, + HttpError, +} from "../vendor/vite-plus-remote-cache-cloudflare/src/errors.ts"; +export { fetchMetadata } from "../vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts"; +export { defaults } from "../vendor/vite-plus-remote-cache-cloudflare/src/limits.ts"; +export { store } from "../vendor/vite-plus-remote-cache-cloudflare/src/store.ts"; +export { readBody, Deadline } from "../vendor/vite-plus-remote-cache-cloudflare/src/streams.ts"; +export { Observations } from "../vendor/vite-plus-remote-cache-cloudflare/src/observations.ts"; + +export const keys = githubKeys(); + +export { authorizeCacheWrite, authorizeTelemetry } from "./telemetryAuth.ts"; diff --git a/infra/task-cache/src/protocolTypes.ts b/infra/task-cache/src/protocolTypes.ts new file mode 100644 index 000000000000..d3ec7e328c97 --- /dev/null +++ b/infra/task-cache/src/protocolTypes.ts @@ -0,0 +1,14 @@ +import type * as Cloudflare from "@cloudflare/workers-types"; + +export interface ProtocolBindings { + readonly INDEX: Cloudflare.D1Database; + readonly ARTIFACTS: Omit & { + get(key: string): Promise< + | (Omit & { + readonly body: ReadableStream; + }) + | null + >; + }; + readonly GC_BATCH_SIZE: string; +} diff --git a/infra/task-cache/src/remoteCache.ts b/infra/task-cache/src/remoteCache.ts new file mode 100644 index 000000000000..5493dbe66dcf --- /dev/null +++ b/infra/task-cache/src/remoteCache.ts @@ -0,0 +1,30 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite configuration also loads before dependency installation. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export function remoteCacheUrl(root: string, env = process.env, home = NodeOS.homedir()) { + if (env.VP_REMOTE_CACHE_URL) return env.VP_REMOTE_CACHE_URL; + try { + const { endpoint } = JSON.parse( + NodeFS.readFileSync(NodePath.join(root, ".task-cache.json"), "utf8"), + ); + const credentials = JSON.parse( + NodeFS.readFileSync(NodePath.join(home, ".config/t3-task-cache/credentials.json"), "utf8"), + ); + const credential = credentials[endpoint]; + if ( + typeof endpoint !== "string" || + typeof credential?.name !== "string" || + typeof credential.token !== "string" + ) + return; + const url = new URL(endpoint); + url.username = credential.name; + url.password = credential.token; + return url.href; + } catch { + // Missing or damaged optional setup must not prevent ordinary project commands. + return; + } +} diff --git a/infra/task-cache/src/resources.ts b/infra/task-cache/src/resources.ts new file mode 100644 index 000000000000..0fb5ba169643 --- /dev/null +++ b/infra/task-cache/src/resources.ts @@ -0,0 +1,46 @@ +import * as Alchemy from "alchemy"; +import { adopt } from "alchemy/AdoptPolicy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import { retain } from "alchemy/RemovalPolicy"; +import * as Effect from "effect/Effect"; +import * as NodeURL from "node:url"; + +const protocol = new URL("../vendor/vite-plus-remote-cache-cloudflare/", import.meta.url); + +export const cacheName = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + return stage === "prod" ? "t3-task-cache" : `t3-task-cache-${stage}`; +}); + +export const Index = cacheName.pipe( + Effect.flatMap((name) => + Cloudflare.D1.Database("Index", { + name, + migrations: NodeURL.fileURLToPath(new URL("migrations", protocol)), + }).pipe(adopt(true), retain()), + ), +); + +export const Artifacts = cacheName.pipe( + Effect.flatMap((name) => + Cloudflare.R2.Bucket("Artifacts", { + name, + lifecycleRules: [ + { + id: "remote-cache-generations", + deleteObjectsTransition: { condition: { type: "Age", maxAge: 9 * 86400 } }, + abortMultipartUploadsTransition: { condition: { type: "Age", maxAge: 86400 } }, + }, + ], + }).pipe(adopt(true), retain()), + ), +); + +export const Savings = cacheName.pipe( + Effect.flatMap((name) => + Cloudflare.D1.Database("Savings", { + name: `${name}-savings`, + migrations: NodeURL.fileURLToPath(new URL("../migrations/savings", import.meta.url)), + }).pipe(retain()), + ), +); diff --git a/infra/task-cache/src/runHook.test.ts b/infra/task-cache/src/runHook.test.ts new file mode 100644 index 000000000000..727890ec2e87 --- /dev/null +++ b/infra/task-cache/src/runHook.test.ts @@ -0,0 +1,496 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercises the real native runner and local HTTP collector. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "vite-plus/test"; + +it("uses one machine hook across fresh worktrees without modifying Vite+", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-hook-")); + const unrelated = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "unrelated-project-")); + const reports: { authorization: string | undefined; body: string }[] = []; + let firstExit!: () => void; + const firstExited = new Promise((resolve) => { + firstExit = resolve; + }); + let completedReports = 0; + let notified: (() => void) | undefined; + let firstDisconnected = false; + const report = (count: number) => + completedReports >= count + ? Promise.resolve() + : new Promise((resolve) => { + notified = resolve; + }); + const server = NodeHttp.createServer(async (request, response) => { + if (!request.url?.endsWith("/telemetry")) { + response.writeHead(404).end(); + return; + } + let body = ""; + for await (const chunk of request) body += chunk; + reports.push({ authorization: request.headers.authorization, body }); + if (reports.length === 1) { + await firstExited; + firstDisconnected = response.destroyed; + } + response.writeHead(204).end(); + completedReports++; + notified?.(); + notified = undefined; + }); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + const runner = NodePath.join(root, "vite-plus"); + const distribution = await NodeFSP.realpath( + NodePath.resolve(import.meta.dirname, "../../../node_modules/vite-plus/dist"), + ); + await NodeFSP.cp(distribution, NodePath.join(runner, "dist"), { recursive: true }); + await NodeFSP.copyFile( + NodePath.join(distribution, "../package.json"), + NodePath.join(runner, "package.json"), + ); + await NodeFSP.symlink( + NodePath.resolve(distribution, "../binding"), + NodePath.join(runner, "binding"), + ); + await NodeFSP.symlink( + NodePath.resolve(distribution, "../.."), + NodePath.join(runner, "node_modules"), + ); + const entrypoint = NodePath.join(runner, "dist/bin.js"); + await NodeFSP.writeFile( + NodePath.join(unrelated, "package.json"), + JSON.stringify({ name: "unrelated", scripts: { build: 'node -e "console.log(7)"' } }), + ); + const nested = NodePath.join(root, "nested"); + await NodeFSP.mkdir(nested); + await NodeFSP.writeFile( + NodePath.join(nested, "package.json"), + JSON.stringify({ name: "nested", scripts: { build: 'node -e "console.log(8)"' } }), + ); + await NodeFSP.writeFile(NodePath.join(nested, "pnpm-workspace.yaml"), "packages: []\n"); + const endpoint = `http://127.0.0.1:${address.port}/projects/test`; + await NodeFSP.writeFile(NodePath.join(root, ".task-cache.json"), JSON.stringify({ endpoint })); + await NodeFSP.writeFile( + NodePath.join(root, "package.json"), + JSON.stringify({ + name: "hook-fixture", + private: true, + scripts: { + build: `node build.cjs ${JSON.stringify(entrypoint)} ${JSON.stringify(unrelated)}`, + fail: 'node -e "process.exit(7)"', + nested: `node ${JSON.stringify(entrypoint)} -C ${JSON.stringify(nested)} run --no-cache build`, + }, + }), + ); + await NodeFSP.writeFile( + NodePath.join(root, "build.cjs"), + 'require("node:fs").writeFileSync("environment.json", JSON.stringify({url: process.env.VP_REMOTE_CACHE_URL ?? null, mode: process.env.VP_REMOTE_CACHE ?? null})); console.log(42); try {for (const cwd of process.argv.slice(3)) require("node:child_process").execFileSync(process.execPath, [process.argv[2], "run", "--no-cache", "build"], {cwd, encoding:"utf8"})} catch (error) { throw new Error(error.stdout + error.stderr) }', + ); + await NodeFSP.writeFile( + NodePath.join(root, "vite.config.ts"), + `import { remoteCacheUrl } from ${JSON.stringify(new URL("./remoteCache.ts", import.meta.url).href)}; export default {run:{cache:{scripts:true,remote:{url:remoteCacheUrl(import.meta.dirname)}}}};`, + ); + await NodeFSP.mkdir(NodePath.join(root, ".config/t3-task-cache"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(root, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ [endpoint]: { name: "maria", token: "secret" } }), + ); + const configDirectory = NodePath.join(root, ".config/vite-plus"); + await NodeFSP.mkdir(configDirectory, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(configDirectory, "run-hook.json"), + JSON.stringify({ + rootMarker: ".task-cache.json", + module: NodePath.join(import.meta.dirname, "runHook.ts"), + }), + ); + const originalEntrypoint = await NodeFSP.readFile(entrypoint, "utf8"); + const run = (task: string, cwd = root, ci = false, cache = "--cache") => + new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [entrypoint, "run", ...(task === "--last-details" ? [] : [cache]), task], + { + cwd, + env: { + ...process.env, + HOME: root, + GITHUB_ACTIONS: ci ? "true" : undefined, + T3_TASK_CACHE_TELEMETRY: undefined, + T3_TASK_CACHE_HOST: undefined, + VITE_CACHE_PATH: undefined, + ACTIONS_ID_TOKEN_REQUEST_URL: undefined, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: undefined, + NODE_OPTIONS: `--import=${new URL("./preload.mjs", import.meta.url).href}`, + VP_REMOTE_CACHE: undefined, + VP_REMOTE_CACHE_URL: undefined, + }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => { + firstExit(); + if (code !== 0 && task !== "fail") reject(new Error(stderr)); + else resolve(code); + }); + }); + expect(await run("build")).toBe(0); + await report(1); + expect(await run("--last-details")).toBe(0); + expect( + JSON.parse(await NodeFSP.readFile(NodePath.join(root, "environment.json"), "utf8")), + ).toMatchObject({ url: null }); + expect(firstDisconnected).toBe(false); + expect(await run("build")).toBe(0); + await report(2); + expect(await run("fail")).toBe(7); + await report(3); + const secondWorktree = NodePath.join(root, "second-worktree"); + await NodeFSP.mkdir(secondWorktree); + for (const file of ["package.json", ".task-cache.json", "vite.config.ts", "build.cjs"]) + await NodeFSP.copyFile(NodePath.join(root, file), NodePath.join(secondWorktree, file)); + expect(await run("build", secondWorktree)).toBe(0); + await report(4); + expect(await run("build", secondWorktree, true)).toBe(0); + expect(completedReports).toBe(5); + expect(await NodeFSP.readFile(entrypoint, "utf8")).toBe(originalEntrypoint); + expect(await run("nested", root, false, "--no-cache")).toBe(0); + expect(await run("build", nested)).toBe(0); + expect(reports).toHaveLength(5); + expect( + reports.every( + (report) => + report.authorization === `Basic ${Buffer.from("maria:secret").toString("base64")}`, + ), + ).toBe(true); + const events = reports.map((report) => JSON.parse(report.body)); + expect(events[0].tasks[0].source).toBe("executed"); + expect(events[1].tasks[0].source).toBe("local"); + expect(events[3].tasks[0].source).toBe("executed"); + expect(new Set(events.map((event) => event.id)).size).toBe(5); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(root, { recursive: true, force: true }); + await NodeFSP.rm(unrelated, { recursive: true, force: true }); + } +}); + +it("rejects invalid setup credentials before installing anything and never prints the token", async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-setup-")); + const token = "private-credential-that-must-not-appear-in-output"; + const server = NodeHttp.createServer((_request, response) => response.writeHead(401).end()); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + const result = await new Promise<{ code: number | null; output: string }>((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [NodePath.join(import.meta.dirname, "../scripts/install.ts"), "--non-interactive"], + { + env: { + ...process.env, + HOME: home, + T3_CACHE_SETUP_URL: `http://127.0.0.1:${address.port}/projects/test`, + T3_CACHE_SETUP_NAME: "maria", + T3_CACHE_SETUP_TOKEN: token, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code, output })); + }); + expect(result.code).not.toBe(0); + expect(result.output).toContain("Credential verification failed (401)"); + expect(result.output).not.toContain(token); + expect(await NodeFSP.readdir(home)).toEqual([]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(home, { recursive: true, force: true }); + } +}); + +it("does not start telemetry or create local state without cache credentials", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-unconfigured-")); + try { + await NodeFSP.writeFile( + NodePath.join(root, ".task-cache.json"), + JSON.stringify({ endpoint: "https://cache.invalid/projects/test" }), + ); + const hook = new URL("./runHook.ts", import.meta.url).href; + const output = await new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [ + "--input-type=module", + "-e", + `import start from ${JSON.stringify(hook)}; const finish = start({ root: process.cwd() }); if (finish !== undefined) process.exit(1);`, + ], + { + cwd: root, + env: { ...process.env, HOME: root, VP_REMOTE_CACHE_URL: "" }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("close", (code) => + code === 0 ? resolve(output) : reject(new Error(output || `Exited ${code}`)), + ); + }); + expect(output).toBe(""); + expect(await NodeFSP.readdir(root)).toEqual([".task-cache.json"]); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); + +it("installs without masking .profile and configures CI through bash", async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-login-")); + const server = NodeHttp.createServer((_request, response) => response.writeHead(204).end()); + const run = (args: string[], env: NodeJS.ProcessEnv) => + new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn(args[0]!, args.slice(1), { + env: { ...process.env, ...env, HOME: home, NODE_OPTIONS: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("close", (code) => (code === 0 ? resolve(output) : reject(new Error(output)))); + }); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + await NodeFSP.writeFile(NodePath.join(home, ".profile"), "export EXISTING_PROFILE=preserved\n"); + await NodeFSP.mkdir(NodePath.join(home, ".config/t3-task-cache"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(home, ".config/t3-task-cache/credentials.json"), + "{partial", + ); + await run( + [ + process.execPath, + NodePath.join(import.meta.dirname, "../scripts/install.ts"), + "--non-interactive", + ], + { + T3_CACHE_SETUP_URL: `http://127.0.0.1:${address.port}/projects/test`, + T3_CACHE_SETUP_NAME: "maria", + T3_CACHE_SETUP_TOKEN: "test-credential-for-isolated-http-server", + }, + ); + expect(await NodeFSP.readdir(home)).not.toContain(".bash_profile"); + expect(await NodeFSP.readFile(NodePath.join(home, ".profile"), "utf8")).toContain( + "EXISTING_PROFILE=preserved", + ); + const githubEnv = NodePath.join(home, "github-env"); + await run([process.execPath, NodePath.join(import.meta.dirname, "../scripts/install-ci.ts")], { + T3_TASK_CACHE_READ_TOKEN: "read-only-fixture-token-01234567890123456789", + GITHUB_REPOSITORY: "pingdotgg/t3code", + GITHUB_ACTIONS: "true", + GITHUB_ENV: githubEnv, + RUNNER_TEMP: home, + }); + const values = await NodeFSP.readFile(githubEnv, "utf8"); + expect(values).not.toContain("\nNODE_OPTIONS="); + const bashEnv = values + .split("\n") + .find((line) => line.startsWith("BASH_ENV=")) + ?.slice(9); + const output = await run(["bash", "-c", 'printf "%s" "$NODE_OPTIONS"'], { BASH_ENV: bashEnv }); + expect(output).toContain("--import=file:"); + expect(output).toContain("/infra/task-cache/src/preload.mjs"); + await NodeFSP.rm(NodePath.join(home, ".local/share/t3-task-cache/preload.mjs")); + const missing = await run(["bash", "-c", `node -e 'console.log("missing preload is safe")'`], { + BASH_ENV: NodePath.join(home, ".config/t3-task-cache/env"), + }); + expect(missing).toContain("missing preload is safe"); + + await NodeFSP.writeFile( + NodePath.join(home, ".bash_profile"), + '. "$HOME/.config/t3-task-cache/env"\n', + ); + await run( + [ + process.execPath, + NodePath.join(import.meta.dirname, "../scripts/install.ts"), + "--uninstall", + ], + {}, + ); + expect(await NodeFSP.readdir(home)).not.toContain(".bash_profile"); + expect(await NodeFSP.readdir(NodePath.join(home, ".local/share/t3-task-cache"))).toEqual([ + "preload.mjs", + ]); + expect(await NodeFSP.readFile(NodePath.join(home, ".profile"), "utf8")).not.toContain( + "t3-task-cache/env", + ); + expect(await NodeFSP.readdir(NodePath.join(home, ".config/t3-task-cache"))).not.toContain( + "credentials.json", + ); + const remaining = await run( + [ + process.execPath, + "--import", + NodePath.join(home, ".local/share/t3-task-cache/preload.mjs"), + "-e", + 'console.log("node survives uninstall")', + ], + {}, + ); + expect(remaining).toContain("node survives uninstall"); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(home, { recursive: true, force: true }); + } +}); + +it("does not send an old endpoint's token after changing the URL interactively", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-endpoint-")); + const requests: string[] = []; + const server = NodeHttp.createServer((request, response) => { + requests.push(request.headers.authorization || ""); + response.writeHead(401).end(); + }); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + const nested = NodePath.join(root, "nested"); + await NodeFSP.mkdir(nested); + await NodeFSP.writeFile( + NodePath.join(nested, "package.json"), + JSON.stringify({ name: "nested", scripts: { build: 'node -e "console.log(8)"' } }), + ); + await NodeFSP.writeFile(NodePath.join(nested, "pnpm-workspace.yaml"), "packages: []\n"); + const endpoint = `http://127.0.0.1:${address.port}/projects/test`; + const scripts = NodePath.join(root, "infra/task-cache/scripts"); + await NodeFSP.mkdir(scripts, { recursive: true }); + await NodeFSP.mkdir(NodePath.join(root, "infra/task-cache/src")); + await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); + await NodeFSP.writeFile(NodePath.join(root, ".task-cache.json"), JSON.stringify({ endpoint })); + await NodeFSP.copyFile( + NodePath.join(import.meta.dirname, "../scripts/install.ts"), + NodePath.join(scripts, "install.ts"), + ); + await NodeFSP.copyFile( + NodePath.join(import.meta.dirname, "nativeHook.js"), + NodePath.join(root, "infra/task-cache/src/nativeHook.js"), + ); + await NodeFSP.mkdir(NodePath.join(root, ".config/t3-task-cache"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(root, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ + [endpoint]: { name: "maria", token: "different-saved-endpoint-credential" }, + }), + ); + const result = await new Promise<{ code: number | null; output: string }>((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [NodePath.join(scripts, "install.ts")], + { + env: { + ...process.env, + HOME: root, + NODE_OPTIONS: "", + T3_CACHE_SETUP_URL: "https://old.example", + T3_CACHE_SETUP_NAME: "maria", + T3_CACHE_SETUP_TOKEN: "old-endpoint-token-must-not-be-forwarded", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let output = ""; + let sentUrl = false; + let sentName = false; + child.stdout.on("data", (chunk) => { + output += chunk; + if (!sentUrl && output.includes("Cache URL")) { + sentUrl = true; + child.stdin.write(endpoint + "\n"); + } + if (!sentName && output.includes("Developer name")) { + sentName = true; + child.stdin.write("\n"); + } + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code, output })); + }); + expect(result.code).not.toBe(0); + expect(result.output).toContain("Use T3_CACHE_SETUP_TOKEN"); + expect(requests).toEqual([]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); + +it("rejects non-loopback HTTP before transmitting a setup credential", async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-http-")); + try { + const output = await new Promise((resolve) => { + NodeChildProcess.execFile( + process.execPath, + [NodePath.join(import.meta.dirname, "../scripts/install.ts"), "--non-interactive"], + { + env: { + ...process.env, + HOME: home, + NODE_OPTIONS: "", + T3_CACHE_SETUP_URL: "http://cache.invalid/projects/test", + T3_CACHE_SETUP_NAME: "maria", + T3_CACHE_SETUP_TOKEN: "test-credential-that-must-not-be-transmitted", + }, + }, + (_error, stdout, stderr) => resolve(stdout + stderr), + ); + }); + expect(output).toContain("Use an HTTPS cache endpoint"); + expect(await NodeFSP.readdir(home)).toEqual([]); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } +}); diff --git a/infra/task-cache/src/runHook.ts b/infra/task-cache/src/runHook.ts new file mode 100644 index 000000000000..4f11fbbd4c26 --- /dev/null +++ b/infra/task-cache/src/runHook.ts @@ -0,0 +1,41 @@ +// @effect-diagnostics nodeBuiltinImport:off - runs inside Vite+ before project services start. +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; +import { remoteCacheUrl } from "./remoteCache.ts"; +import { flushTelemetry, observeSummaries } from "./telemetry.ts"; + +export default function start({ root }: { readonly root: string }) { + const url = remoteCacheUrl(root); + if (!url) return; + const endpoint = new URL(url); + endpoint.username = ""; + endpoint.password = ""; + const stop = + process.env.T3_TASK_CACHE_TELEMETRY === "off" + ? undefined + : observeSummaries(root, endpoint.href.replace(/\/$/, ""), process.env); + const finish = async () => { + if (!stop) return; + stop(); + // GitHub can terminate detached processes as soon as the final step exits. + if (process.env.GITHUB_ACTIONS === "true") { + await flushTelemetry(); + return; + } + // A transient flusher drains the durable queue without delaying tasks or Ctrl-C. + const child = NodeChildProcess.spawn( + process.execPath, + [NodeURL.fileURLToPath(new URL("./flush.ts", import.meta.url))], + { + detached: true, + stdio: "ignore", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + child.on("error", () => {}); + child.unref(); + }; + return Object.assign(finish, { + remoteCache: process.env.VP_REMOTE_CACHE || process.env.VP_T3_TASK_CACHE_MODE || "read-write", + }); +} diff --git a/infra/task-cache/src/telemetry.test.ts b/infra/task-cache/src/telemetry.test.ts new file mode 100644 index 000000000000..024aed74bed0 --- /dev/null +++ b/infra/task-cache/src/telemetry.test.ts @@ -0,0 +1,162 @@ +// @effect-diagnostics nodeBuiltinImport:off - launcher integration with filesystem and fetch. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it, vi } from "vite-plus/test"; +import { flushTelemetry, observeSummaries, summarize } from "./telemetry.ts"; + +const task = (result: unknown) => ({ + package_name: "@team/server", + task_name: "typecheck", + command: "SECRET", + cwd: "/private", + result, +}); + +it("counts only confirmed hits, preserves their tier, and never transmits command text", () => { + expect( + summarize({ + tasks: [ + task({ CacheHit: { source: "Remote", saved_duration_ms: 1200 } }), + task({ CacheHit: { source: "Local", saved_duration_ms: 500 } }), + task({ RestoreFailed: {} }), + task({ Spawned: {} }), + task({ CacheHit: { saved_duration_ms: 999 } }), + task({ CacheHit: { source: "Remote", saved_duration_ms: -1 } }), + ], + }), + ).toEqual([ + { package: "@team/server", task: "typecheck", source: "remote", savedMs: 1200 }, + { package: "@team/server", task: "typecheck", source: "local", savedMs: 500 }, + { package: "@team/server", task: "typecheck", source: "failed", savedMs: 0 }, + { package: "@team/server", task: "typecheck", source: "executed", savedMs: 0 }, + { package: "@team/server", task: "typecheck", source: "unspecified", savedMs: 999 }, + ]); +}); + +it("deduplicates shared summaries and retries delivery without credentials in queued records", async () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "cache-observer-")); + const home = NodePath.join(root, "home"); + const endpoint = "https://cache.example/projects/t3code"; + try { + const stopA = observeSummaries(root, endpoint, { T3_TASK_CACHE_HOST: "test-host" }, home); + const stopB = observeSummaries(root, endpoint, { T3_TASK_CACHE_HOST: "test-host" }, home); + const version = NodePath.join(root, "node_modules/.vite/task-cache/v18"); + NodeFS.mkdirSync(version); + NodeFS.writeFileSync( + NodePath.join(version, "last-summary.json"), + JSON.stringify({ + tasks: [task({ CacheHit: { source: "Remote", saved_duration_ms: 1200 } })], + }), + ); + stopA(); + stopB(); + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + const files = NodeFS.readdirSync(queue); + expect(files).toHaveLength(1); + const serialized = NodeFS.readFileSync(NodePath.join(queue, files[0]!), "utf8"); + expect(serialized).not.toContain("SECRET"); + expect(JSON.parse(serialized).event.host).toBe("test-host"); + NodeFS.mkdirSync(NodePath.join(home, ".config/t3-task-cache"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(home, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ [endpoint]: { name: "alice", token: "secret-token" } }), + ); + using request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockImplementationOnce(async () => { + const late = JSON.parse(serialized); + late.event.id = "b".repeat(64); + NodeFS.writeFileSync(NodePath.join(queue, "late.json"), JSON.stringify(late)); + return new Response(null, { status: 204 }); + }) + .mockResolvedValue(new Response(null, { status: 204 })); + await flushTelemetry(home, {}); + expect(NodeFS.readdirSync(queue)).toEqual(files); + await flushTelemetry(home, {}); + expect(NodeFS.readdirSync(queue)).toEqual([]); + expect(request).toHaveBeenCalledTimes(3); + expect(request.mock.calls[0]![0].toString()).toBe(`${endpoint}/telemetry`); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("skips unauthenticated endpoints without consuming the delivery budget", async () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "cache-queue-")); + const endpoint = "https://cache.example/projects/t3code"; + try { + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + NodeFS.mkdirSync(queue, { recursive: true }); + const skipped = Array.from({ length: 101 }, (_, i) => `a${i}.json`); + for (const file of skipped) + NodeFS.writeFileSync( + NodePath.join(queue, file), + JSON.stringify({ endpoint: "https://old.example/projects/t3code", event: { id: file } }), + ); + NodeFS.writeFileSync( + NodePath.join(queue, "b.json"), + JSON.stringify({ endpoint, event: { id: "current" } }), + ); + NodeFS.mkdirSync(NodePath.join(home, ".config/t3-task-cache"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(home, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ [endpoint]: { name: "alice", token: "secret-token" } }), + ); + using request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 204 })); + await flushTelemetry(home, {}); + expect(NodeFS.readdirSync(queue).sort()).toEqual(skipped.sort()); + expect(request).toHaveBeenCalledOnce(); + expect(request.mock.calls[0]![0].toString()).toBe(`${endpoint}/telemetry`); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } +}); + +it("excludes tasks that explicitly disable caching from the hit-rate denominator", () => { + expect( + summarize({ + tasks: [ + task({ Spawned: { cache_status: "Disabled" } }), + task({ Spawned: { cache_status: { Miss: "changed" } } }), + ], + }), + ).toEqual([{ package: "@team/server", task: "typecheck", source: "executed", savedMs: 0 }]); +}); + +it("drops revoked credentials and continues delivering other queued endpoints", async () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "cache-revoked-")); + try { + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + const state = NodePath.join(home, ".config/t3-task-cache"); + NodeFS.mkdirSync(queue, { recursive: true }); + NodeFS.mkdirSync(state, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(state, "credentials.json"), + JSON.stringify({ + "https://revoked.example": { name: "a", token: "old" }, + "https://current.example": { name: "a", token: "new" }, + }), + ); + for (const [file, endpoint] of [ + ["a.json", "https://revoked.example"], + ["b.json", "https://current.example"], + ]) + NodeFS.writeFileSync( + NodePath.join(queue, file!), + JSON.stringify({ endpoint, event: { id: file } }), + ); + using request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(null, { status: 403 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + await flushTelemetry(home, {}); + expect(request).toHaveBeenCalledTimes(2); + expect(NodeFS.readdirSync(queue)).toEqual([]); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/infra/task-cache/src/telemetry.ts b/infra/task-cache/src/telemetry.ts new file mode 100644 index 000000000000..509167b6c023 --- /dev/null +++ b/infra/task-cache/src/telemetry.ts @@ -0,0 +1,256 @@ +/* oxlint-disable t3code/no-global-process-runtime -- This bootstrap runs before dependencies are installed. */ +// @effect-diagnostics globalDate:off globalFetch:off - bootstrap has no Effect dependencies. +// @effect-diagnostics nodeBuiltinImport:off - standalone collector installed outside the checkout. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export interface SummaryEvent { + id: string; + recordedAt: number; + host: string; + platform: string; + arch: string; + ci: boolean; + runId: string; + tasks: { + package: string; + task: string; + source: "local" | "remote" | "unspecified" | "executed" | "failed"; + savedMs: number; + }[]; +} + +export function summarize(value: unknown): SummaryEvent["tasks"] { + if (!value || typeof value !== "object" || !("tasks" in value) || !Array.isArray(value.tasks)) + return []; + return value.tasks.flatMap((task: unknown) => { + if ( + !task || + typeof task !== "object" || + !("package_name" in task) || + !("task_name" in task) || + !("result" in task) + ) + return []; + if ( + typeof task.package_name !== "string" || + typeof task.task_name !== "string" || + !task.result || + typeof task.result !== "object" + ) + return []; + let source: SummaryEvent["tasks"][number]["source"] = "executed"; + let savedMs = 0; + if ("CacheHit" in task.result) { + const hit = task.result.CacheHit; + if ( + !hit || + typeof hit !== "object" || + !("saved_duration_ms" in hit) || + typeof hit.saved_duration_ms !== "number" || + !Number.isSafeInteger(hit.saved_duration_ms) || + hit.saved_duration_ms < 0 + ) + return []; + if ("source" in hit && hit.source !== "Local" && hit.source !== "Remote") return []; + source = !("source" in hit) ? "unspecified" : hit.source === "Remote" ? "remote" : "local"; + savedMs = hit.saved_duration_ms; + } else if ("RestoreFailed" in task.result) source = "failed"; + else if (!("Spawned" in task.result)) return []; + else if ( + task.result.Spawned && + typeof task.result.Spawned === "object" && + "cache_status" in task.result.Spawned && + task.result.Spawned.cache_status === "Disabled" + ) + return []; + return [ + { + package: task.package_name.slice(0, 256), + task: task.task_name.slice(0, 256), + source, + savedMs, + }, + ]; + }); +} + +export function observeSummaries( + root: string, + endpoint: string, + env: NodeJS.ProcessEnv, + home = NodeOS.homedir(), +) { + const cache = env.VITE_CACHE_PATH || NodePath.join(root, "node_modules/.vite/task-cache"); + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + let host = + env.T3_TASK_CACHE_HOST || + (env.GITHUB_ACTIONS === "true" + ? `${env.RUNNER_ENVIRONMENT || "github"}/${env.RUNNER_OS || NodeOS.platform()}/${env.RUNNER_ARCH || NodeOS.arch()}` + : NodeOS.hostname()); + try { + host = + env.T3_TASK_CACHE_HOST || + NodeFS.readFileSync(NodePath.join(home, ".agents/MACHINE"), "utf8").trim() || + host; + } catch { + /* Machine labels are optional outside the fleet. */ + } + const seen = new Set(); + const snapshot = (file: string, enqueue: boolean) => { + try { + const before = NodeFS.statSync(file, { bigint: true }); + const contents = NodeFS.readFileSync(file, "utf8"); + const after = NodeFS.statSync(file, { bigint: true }); + if (before.ino !== after.ino || before.mtimeNs !== after.mtimeNs) return; + // Nested/concurrent launchers see the same summary; stable ids prevent double counting. + const id = NodeCrypto.createHash("sha256") + .update(`${host}\0${root}\0${before.ino}\0${before.mtimeNs}\0${contents}`) + .digest("hex"); + if (seen.has(id)) return; + seen.add(id); + if (!enqueue) return; + const tasks = summarize(JSON.parse(contents)); + if (!tasks.length) return; + const event: SummaryEvent = { + id, + recordedAt: Number(before.mtimeNs / 1_000_000n), + host: host.slice(0, 256), + platform: NodeOS.platform(), + arch: NodeOS.arch(), + ci: env.GITHUB_ACTIONS === "true", + runId: env.GITHUB_RUN_ID || "", + tasks, + }; + NodeFS.mkdirSync(queue, { recursive: true, mode: 0o700 }); + const temporary = NodePath.join(queue, `${id}.${process.pid}.tmp`); + NodeFS.writeFileSync(temporary, JSON.stringify({ endpoint, event }), { mode: 0o600 }); + NodeFS.renameSync(temporary, NodePath.join(queue, `${id}.json`)); + } catch { + /* Telemetry must never prevent task execution. */ + } + }; + const scan = (enqueue: boolean) => { + for (const entry of NodeFS.readdirSync(cache, { withFileTypes: true })) { + if (entry.isDirectory()) + snapshot(NodePath.join(cache, entry.name, "last-summary.json"), enqueue); + } + }; + try { + NodeFS.mkdirSync(cache, { recursive: true }); + scan(false); + // Capture nested runs before the parent's atomic summary replaces them. + const watcher = NodeFS.watch(cache, { recursive: true }, (_event, file) => { + if (file?.endsWith("last-summary.json")) snapshot(NodePath.join(cache, file), true); + }); + watcher.on("error", () => watcher.close()); + return () => { + watcher.close(); + try { + scan(true); + } catch { + /* A task may remove its cache directory. */ + } + }; + } catch { + return () => {}; + } +} + +export async function flushTelemetry(home = NodeOS.homedir(), env = process.env) { + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + const lock = NodePath.join(queue, "flush.lock"); + try { + NodeFS.mkdirSync(queue, { recursive: true, mode: 0o700 }); + try { + if (Date.now() - NodeFS.statSync(lock).mtimeMs > 60_000) NodeFS.rmdirSync(lock); + } catch { + /* No previous flusher. */ + } + NodeFS.mkdirSync(lock); + } catch { + return; + } + try { + const files = NodeFS.readdirSync(queue) + .filter((file) => file.endsWith(".json")) + .sort( + (a, b) => + NodeFS.statSync(NodePath.join(queue, a)).mtimeMs - + NodeFS.statSync(NodePath.join(queue, b)).mtimeMs, + ); + // Bound disk use while offline. Unsent events retry on the next invocation. + for (const file of files.slice(0, Math.max(0, files.length - 2000))) + NodeFS.rmSync(NodePath.join(queue, file), { force: true }); + const credentialsFile = NodePath.join(home, ".config/t3-task-cache/credentials.json"); + const credentials = NodeFS.existsSync(credentialsFile) + ? JSON.parse(NodeFS.readFileSync(credentialsFile, "utf8")) + : {}; + const deadline = Date.now() + 15_000; + const skipped = new Set(); + // Re-scan between sends so a concurrent command's final summary joins this flush. + for (let sent = 0; sent < 100 && Date.now() < deadline;) { + const file = NodeFS.readdirSync(queue) + .filter((entry) => entry.endsWith(".json") && !skipped.has(entry)) + .sort()[0]; + if (!file) break; + const path = NodePath.join(queue, file); + const { endpoint, event } = JSON.parse(NodeFS.readFileSync(path, "utf8")); + const url = new URL(endpoint); + const configured = env.VP_REMOTE_CACHE_URL ? new URL(env.VP_REMOTE_CACHE_URL) : undefined; + const configuredEndpoint = configured && new URL(configured.href); + if (configuredEndpoint) { + configuredEndpoint.username = ""; + configuredEndpoint.password = ""; + } + const credential = + configured?.username && configuredEndpoint?.href.replace(/\/$/, "") === endpoint + ? { + name: decodeURIComponent(configured.username), + token: decodeURIComponent(configured.password), + } + : credentials[endpoint]; + let authorization: string; + if ( + credential && + !( + env.GITHUB_ACTIONS === "true" && + env.ACTIONS_ID_TOKEN_REQUEST_URL && + env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + ) + ) + authorization = `Basic ${Buffer.from(`${credential.name}:${credential.token}`).toString("base64")}`; + else if (env.ACTIONS_ID_TOKEN_REQUEST_URL && env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) { + const tokenUrl = new URL(env.ACTIONS_ID_TOKEN_REQUEST_URL); + tokenUrl.searchParams.set("audience", endpoint); + const response = await fetch(tokenUrl, { + headers: { Authorization: `Bearer ${env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` }, + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) return; + const token = (await response.json()) as { value: string }; + authorization = `Bearer ${token.value}`; + } else { + skipped.add(file); + continue; + } + sent++; + url.pathname = `${url.pathname.replace(/\/$/, "")}/telemetry`; + const response = await fetch(url, { + method: "POST", + headers: { Authorization: authorization, "Content-Type": "application/json" }, + body: JSON.stringify(event), + signal: AbortSignal.timeout(3000), + }); + await response.body?.cancel(); + if (response.ok || [400, 401, 403, 413].includes(response.status)) NodeFS.unlinkSync(path); + else return; + } + } catch { + /* Leave unsent events for a later invocation. */ + } finally { + NodeFS.rmdirSync(lock); + } +} diff --git a/infra/task-cache/src/telemetryAuth.test.ts b/infra/task-cache/src/telemetryAuth.test.ts new file mode 100644 index 000000000000..7b04a89079a7 --- /dev/null +++ b/infra/task-cache/src/telemetryAuth.test.ts @@ -0,0 +1,84 @@ +import { generateKeyPair, SignJWT } from "jose"; +import { expect, it, vi } from "vite-plus/test"; +import { authorizeCacheWrite, authorizeTelemetry } from "./telemetryAuth.ts"; +import { authorize, ISSUER } from "../vendor/vite-plus-remote-cache-cloudflare/src/auth.ts"; + +it("accepts signed stacked PR telemetry without granting cache writes or trusting another repository", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1_000_000); + try { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const scope = { + scope_id: "t3code", + endpoint: "https://cache.example/projects/t3code", + repository_id: "123", + repository_owner_id: "456", + branch: "refs/heads/main", + policy_version: 1, + writes_enabled: 0, + pull_requests: 0, + }; + const token = async (repository_id: string, audience = scope.endpoint) => + new SignJWT({ + repository_id, + repository_owner_id: "456", + repository_visibility: "public", + event_name: "pull_request", + ref: "refs/pull/42/merge", + ref_type: "branch", + base_ref: "feature/parent", + actor: "maria", + }) + .setProtectedHeader({ alg: "RS256", kid: "test" }) + .setIssuer(ISSUER) + .setAudience(audience) + .setIssuedAt(1000) + .setNotBefore(1000) + .setExpirationTime(1600) + .sign(privateKey); + const request = (token: string) => + new Request(scope.endpoint, { headers: { Authorization: `Bearer ${token}` } }); + const keys = async () => publicKey; + const valid = request(await token("123")); + const allowed = { + ...scope, + writes_enabled: 1, + pull_requests: 1, + branch: "refs/heads/feature/parent", + }; + expect( + await authorizeCacheWrite( + request(await token("123", "https://ci-reader:secret@cache.example/projects/t3code")), + allowed, + keys, + ), + ).toMatchObject({ repository_id: "123" }); + await expect( + authorizeCacheWrite( + request(await token("123", "https://ci-reader:secret@other.example/projects/t3code")), + allowed, + keys, + ), + ).rejects.toMatchObject({ status: 403 }); + expect(await authorizeTelemetry(valid, scope, keys)).toBe("github/maria"); + await expect( + authorizeCacheWrite(valid, { ...allowed, pull_requests: 0 }, keys), + ).rejects.toMatchObject({ status: 403 }); + await expect( + authorizeCacheWrite(valid, { ...allowed, branch: "refs/heads/main" }, keys), + ).rejects.toMatchObject({ status: 403 }); + await expect(authorize(valid, scope, keys)).rejects.toMatchObject({ status: 403 }); + await expect(authorizeCacheWrite(valid, scope, keys)).rejects.toMatchObject({ status: 403 }); + expect(await authorizeCacheWrite(valid, allowed, keys)).toMatchObject({ + repository_id: "123", + }); + await expect( + authorizeCacheWrite(request(await token("another-repo")), allowed, keys), + ).rejects.toMatchObject({ status: 403 }); + await expect( + authorizeTelemetry(request(await token("another-repo")), scope, keys), + ).rejects.toMatchObject({ status: 403 }); + } finally { + vi.useRealTimers(); + } +}); diff --git a/infra/task-cache/src/telemetryAuth.ts b/infra/task-cache/src/telemetryAuth.ts new file mode 100644 index 000000000000..37a59e231817 --- /dev/null +++ b/infra/task-cache/src/telemetryAuth.ts @@ -0,0 +1,44 @@ +import { decodeJwt, type JWTVerifyGetKey } from "jose"; +import { authorize } from "../vendor/vite-plus-remote-cache-cloudflare/src/auth.ts"; +import type { Scope } from "../vendor/vite-plus-remote-cache-cloudflare/src/database.ts"; +import { HttpError } from "../vendor/vite-plus-remote-cache-cloudflare/src/errors.ts"; + +function audienceScope(request: Request, scope: Scope, telemetry = false) { + const authorization = request.headers.get("authorization"); + if (!authorization || authorization.length > 16 * 1024) throw new HttpError(401, "invalid_token"); + let payload; + try { + payload = decodeJwt(authorization.slice(7)); + } catch { + throw new HttpError(401, "invalid_token"); + } + // The decoded base only selects policy; authorize verifies all claims and the signature. + const branch = + telemetry && payload.event_name === "pull_request" && typeof payload.base_ref === "string" + ? `refs/heads/${payload.base_ref}` + : scope.branch; + let endpoint = scope.endpoint; + if (typeof payload.aud === "string") { + try { + const audience = new URL(payload.aud); + audience.username = ""; + audience.password = ""; + // Vite+ uses the configured URL, including read credentials, as its OIDC audience. + if (audience.href.replace(/\/$/, "") === scope.endpoint.replace(/\/$/, "")) + endpoint = payload.aud; + } catch { + // The verified authorization below rejects malformed and foreign audiences. + } + } + return { ...scope, branch, endpoint, pull_requests: telemetry ? 1 : scope.pull_requests }; +} + +export async function authorizeCacheWrite(request: Request, scope: Scope, keys: JWTVerifyGetKey) { + return authorize(request, audienceScope(request, scope), keys); +} + +export async function authorizeTelemetry(request: Request, scope: Scope, keys: JWTVerifyGetKey) { + await authorize(request, audienceScope(request, { ...scope, writes_enabled: 1 }, true), keys); + const payload = decodeJwt(request.headers.get("authorization")!.slice(7)); + return `github/${typeof payload.actor === "string" ? payload.actor : "unknown"}`; +} diff --git a/infra/task-cache/src/worker.ts b/infra/task-cache/src/worker.ts new file mode 100644 index 000000000000..5fc8eac15c3a --- /dev/null +++ b/infra/task-cache/src/worker.ts @@ -0,0 +1,98 @@ +import * as Axiom from "alchemy/Axiom"; +import { Observability } from "./observability.ts"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import * as Admission from "./Admission.ts"; +import * as Cache from "./Cache.ts"; +import * as Savings from "./Savings.ts"; +import type { ProtocolBindings } from "./protocolTypes.ts"; +import * as Resources from "./resources.ts"; + +export class Worker extends Cloudflare.Worker()("Cache") {} + +export const layer = Worker.make( + Resources.cacheName.pipe( + Effect.map((name) => ({ + name, + main: import.meta.filename, + compatibility: { date: "2026-09-11", flags: ["nodejs_compat"] }, + })), + ), + Effect.gen(function* () { + const savings = yield* Cloudflare.D1.QueryDatabase(yield* Resources.Savings); + const database = yield* Cloudflare.D1.QueryDatabase(yield* Resources.Index); + const artifacts = yield* Cloudflare.R2.ReadWriteBucket(yield* Resources.Artifacts); + const developerKeys = yield* Config.Redacted("DEVELOPER_KEYS"); + const readerKeys = yield* Config.Redacted("READER_KEYS"); + const reads = yield* Cloudflare.RateLimit("READ_LIMITER", { + namespaceId: "1001", + simple: { limit: 600, period: 60 }, + }); + const writes = yield* Cloudflare.RateLimit("STORE_LIMITER", { + namespaceId: "1002", + simple: { limit: 600, period: 60 }, + }); + const bindings = Effect.gen(function* () { + return Cache.Bindings.of({ + INDEX: yield* database.raw, + ARTIFACTS: (yield* artifacts.raw) as unknown as ProtocolBindings["ARTIFACTS"], + GC_BATCH_SIZE: "16", + developerKeys: Redacted.value(developerKeys), + readerKeys: Redacted.value(readerKeys), + readLimit: yield* reads.raw, + storeLimit: yield* writes.raw, + }); + }); + const admission = yield* Admission.Admission.pipe(Effect.provide(Admission.layer)); + const savingsLayer = Savings.layer.pipe( + Layer.provide(Layer.effect(Savings.Database, savings.raw)), + ); + const runtime = Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Admission.Admission, admission), + Layer.effect(Cache.Bindings, bindings), + savingsLayer, + ]), + ); + yield* Cloudflare.Workers.cron("*/5 * * * *", () => + Cache.Cache.pipe( + Effect.flatMap((cache) => cache.cleanup), + Effect.provide(runtime), + Effect.orDie, + ), + ); + yield* Cloudflare.Workers.cron("0 * * * *", () => + Savings.Savings.pipe( + Effect.flatMap((savings) => savings.cleanup.pipe(Effect.andThen(savings.emitTotals()))), + Effect.provide(savingsLayer), + Effect.orDie, + ), + ); + return { + fetch: Effect.gen(function* () { + const request = yield* Cloudflare.Workers.Request; + const context = yield* Cloudflare.Workers.WorkerExecutionContext; + const cache = yield* Cache.Cache; + return HttpServerResponse.fromWeb(yield* cache.handle(request, context.raw)); + }).pipe(Effect.provide(runtime)), + }; + }).pipe( + Effect.provide([ + Cloudflare.D1.QueryDatabaseBinding, + Cloudflare.R2.ReadWriteBucketBinding, + Cloudflare.Workers.RateLimitBinding, + Cloudflare.Workers.CronEventSourceLive, + Layer.unwrap( + Effect.map(Observability, ({ traces, token }) => + Axiom.Telemetry({ serviceName: "t3-task-cache", traces, token }), + ), + ), + ]), + ), +); +// Alchemy's generated Worker entry imports the runtime layer as the default export. +export default layer; diff --git a/infra/task-cache/tsconfig.json b/infra/task-cache/tsconfig.json new file mode 100644 index 000000000000..85e659902b40 --- /dev/null +++ b/infra/task-cache/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "lib": ["ESNext", "DOM"], + "types": ["node"], + "erasableSyntaxOnly": false + }, + "include": ["alchemy.run.ts", "src/**/*.ts", "scripts/**/*.ts", "types/**/*.d.ts"] +} diff --git a/infra/task-cache/types/upstream.d.ts b/infra/task-cache/types/upstream.d.ts new file mode 100644 index 000000000000..9a9a548e46e6 --- /dev/null +++ b/infra/task-cache/types/upstream.d.ts @@ -0,0 +1,12 @@ +// The source-only upstream package expects Wrangler-generated globals but does not ship them. +import type * as Cloudflare from "@cloudflare/workers-types"; +import type { ProtocolBindings } from "../src/protocolTypes.ts"; + +declare global { + interface Env extends ProtocolBindings {} + type D1Database = Cloudflare.D1Database; + type D1Result = Cloudflare.D1Result; + type R2MultipartUpload = Cloudflare.R2MultipartUpload; + type R2UploadedPart = Cloudflare.R2UploadedPart; + type ExecutionContext = Cloudflare.ExecutionContext; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/LICENSE b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/LICENSE new file mode 100644 index 000000000000..cf067b10e0dc --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026-present, VoidZero Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/README.md b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/README.md new file mode 100644 index 000000000000..ff34390cf62f --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/README.md @@ -0,0 +1 @@ +Vendored from [vite-plus-remote-cache-cloudflare](https://github.com/voidzero-dev/vite-plus-remote-cache-cloudflare/tree/5b06c2c10d84ea441735ba07c46f6d1b7cb2ff3b) at commit `5b06c2c10d84ea441735ba07c46f6d1b7cb2ff3b`. Source and migrations match upstream except for Effect diagnostic suppression comments; the package manifest omits upstream development tooling. MIT license is included. This avoids requiring GitHub downloads for contributor installs. diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0001_cache.sql b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0001_cache.sql new file mode 100644 index 000000000000..31f73fa03b58 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0001_cache.sql @@ -0,0 +1,160 @@ +-- Primary D1 owns policy and accounting. Back up policies separately from cache data. +CREATE TABLE deployment ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + writes_enabled INTEGER NOT NULL DEFAULT 1 CHECK (writes_enabled IN (0, 1)), + byte_limit INTEGER NOT NULL DEFAULT 8000000000 CHECK (byte_limit > 0), + entry_limit INTEGER NOT NULL DEFAULT 20000 CHECK (entry_limit > 0), + association_limit INTEGER NOT NULL DEFAULT 20000 CHECK (association_limit > 0), + retention_high_water_seconds INTEGER NOT NULL DEFAULT 604800, + charged_bytes INTEGER NOT NULL DEFAULT 0 CHECK (charged_bytes >= 0), + entry_count INTEGER NOT NULL DEFAULT 0 CHECK (entry_count >= 0), + association_count INTEGER NOT NULL DEFAULT 0 CHECK (association_count >= 0) +); +INSERT INTO deployment (id) VALUES (1); +CREATE TABLE scopes ( + scope_id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + repository TEXT NOT NULL, + repository_id TEXT NOT NULL, + repository_owner_id TEXT NOT NULL, + branch TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + writes_enabled INTEGER NOT NULL DEFAULT 1 CHECK (writes_enabled IN (0, 1)), + policy_version INTEGER NOT NULL DEFAULT 1, + retention_seconds INTEGER NOT NULL DEFAULT 604800 CHECK (retention_seconds > 0), + byte_limit INTEGER NOT NULL DEFAULT 8000000000 CHECK (byte_limit > 0), + entry_limit INTEGER NOT NULL DEFAULT 20000 CHECK (entry_limit > 0), + association_limit INTEGER NOT NULL DEFAULT 20000 CHECK (association_limit > 0), + charged_bytes INTEGER NOT NULL DEFAULT 0 CHECK (charged_bytes >= 0), + entry_count INTEGER NOT NULL DEFAULT 0 CHECK (entry_count >= 0), + association_count INTEGER NOT NULL DEFAULT 0 CHECK (association_count >= 0) +); +CREATE TABLE generations ( + generation_id TEXT PRIMARY KEY, + scope_id TEXT NOT NULL REFERENCES scopes(scope_id), + state TEXT NOT NULL CHECK (state IN ('uploading', 'ready', 'retired', 'deleting')), + policy_version INTEGER NOT NULL, + token_exp INTEGER NOT NULL, + lease_until INTEGER NOT NULL, + gc_after INTEGER NOT NULL, + gc_claim TEXT, + key BLOB, + secondary_key BLOB, + value_object TEXT NOT NULL UNIQUE, + blob_object TEXT NOT NULL UNIQUE, + blob_id TEXT, + upload_id TEXT, + value_size INTEGER NOT NULL DEFAULT 0 CHECK (value_size >= 0), + blob_size INTEGER NOT NULL DEFAULT 0 CHECK (blob_size >= 0), + charged_bytes INTEGER NOT NULL CHECK (charged_bytes >= 0), + expires_at INTEGER, + retired_at INTEGER, + UNIQUE (scope_id, generation_id) +); +CREATE UNIQUE INDEX generations_blob ON generations(scope_id, blob_id) WHERE blob_id IS NOT NULL; +CREATE INDEX generations_gc ON generations(gc_after); +CREATE TABLE entries ( + scope_id TEXT NOT NULL REFERENCES scopes(scope_id), + key BLOB NOT NULL CHECK (typeof(key) = 'blob'), + generation_id TEXT NOT NULL, + PRIMARY KEY (scope_id, key), + FOREIGN KEY (scope_id, generation_id) REFERENCES generations(scope_id, generation_id) ON DELETE CASCADE +) WITHOUT ROWID; +CREATE INDEX entries_generation ON entries(generation_id); +CREATE TABLE associations ( + scope_id TEXT NOT NULL REFERENCES scopes(scope_id), + secondary_key BLOB NOT NULL CHECK (typeof(secondary_key) = 'blob'), + target_key BLOB NOT NULL CHECK (typeof(target_key) = 'blob'), + PRIMARY KEY (scope_id, secondary_key) +) WITHOUT ROWID; +CREATE INDEX associations_target ON associations(scope_id, target_key); +CREATE TABLE maintenance (id INTEGER PRIMARY KEY CHECK (id = 1), scope_id TEXT NOT NULL, secondary_key BLOB NOT NULL); +INSERT INTO maintenance VALUES (1, '', X''); + +-- Parenthesize CASE expressions so the D1 REST parser preserves each trigger body. +-- Lifecycle rules must never shorten the life of an already published generation. +CREATE TRIGGER initial_retention AFTER INSERT ON scopes BEGIN + UPDATE deployment SET retention_high_water_seconds = max(retention_high_water_seconds, NEW.retention_seconds); +END; +CREATE TRIGGER increased_retention AFTER UPDATE OF retention_seconds ON scopes BEGIN + UPDATE deployment SET retention_high_water_seconds = max(retention_high_water_seconds, NEW.retention_seconds); +END; +CREATE TRIGGER scope_limit BEFORE INSERT ON scopes +WHEN NOT EXISTS (SELECT 1 FROM scopes WHERE scope_id = NEW.scope_id) +BEGIN + SELECT (CASE WHEN (SELECT count(*) FROM scopes) >= 100 THEN RAISE(ABORT, 'cache_scope_limit') END); +END; + +-- Direct policy edits must invalidate uploads that captured the previous policy. +CREATE TRIGGER changed_policy AFTER UPDATE OF endpoint, repository_id, repository_owner_id, branch, + enabled, writes_enabled, retention_seconds, byte_limit, entry_limit, association_limit ON scopes +WHEN NEW.policy_version = OLD.policy_version +BEGIN + UPDATE scopes SET policy_version = OLD.policy_version + 1 WHERE scope_id = NEW.scope_id; +END; + +CREATE TRIGGER reserve_capacity BEFORE INSERT ON generations BEGIN + SELECT (CASE WHEN NOT EXISTS ( + SELECT 1 FROM scopes s, deployment d WHERE s.scope_id = NEW.scope_id + AND s.enabled = 1 AND s.writes_enabled = 1 AND d.enabled = 1 AND d.writes_enabled = 1 + AND s.policy_version = NEW.policy_version AND NEW.token_exp > unixepoch() + AND s.charged_bytes + NEW.charged_bytes <= s.byte_limit + AND d.charged_bytes + NEW.charged_bytes <= d.byte_limit + ) THEN RAISE(ABORT, 'cache_admission_denied') END); +END; +CREATE TRIGGER charge_generation AFTER INSERT ON generations BEGIN + UPDATE scopes SET charged_bytes = charged_bytes + NEW.charged_bytes WHERE scope_id = NEW.scope_id; + UPDATE deployment SET charged_bytes = charged_bytes + NEW.charged_bytes; +END; +CREATE TRIGGER adjust_generation AFTER UPDATE OF charged_bytes ON generations BEGIN + UPDATE scopes SET charged_bytes = charged_bytes + NEW.charged_bytes - OLD.charged_bytes WHERE scope_id = NEW.scope_id; + UPDATE deployment SET charged_bytes = charged_bytes + NEW.charged_bytes - OLD.charged_bytes; +END; +CREATE TRIGGER release_generation AFTER DELETE ON generations BEGIN + UPDATE scopes SET charged_bytes = charged_bytes - OLD.charged_bytes WHERE scope_id = OLD.scope_id; + UPDATE deployment SET charged_bytes = charged_bytes - OLD.charged_bytes; +END; + +CREATE TRIGGER count_entry BEFORE INSERT ON entries +WHEN NOT EXISTS (SELECT 1 FROM entries WHERE scope_id = NEW.scope_id AND key = NEW.key) +BEGIN + SELECT (CASE WHEN EXISTS ( + SELECT 1 FROM scopes s, deployment d WHERE s.scope_id = NEW.scope_id + AND (s.entry_count >= s.entry_limit OR d.entry_count >= d.entry_limit) + ) THEN RAISE(ABORT, 'cache_entry_limit') END); + UPDATE scopes SET entry_count = entry_count + 1 WHERE scope_id = NEW.scope_id; + UPDATE deployment SET entry_count = entry_count + 1; +END; +CREATE TRIGGER uncount_entry AFTER DELETE ON entries BEGIN + UPDATE scopes SET entry_count = entry_count - 1 WHERE scope_id = OLD.scope_id; + UPDATE deployment SET entry_count = entry_count - 1; +END; +CREATE TRIGGER count_association BEFORE INSERT ON associations +WHEN NOT EXISTS (SELECT 1 FROM associations WHERE scope_id = NEW.scope_id AND secondary_key = NEW.secondary_key) +BEGIN + SELECT (CASE WHEN EXISTS ( + SELECT 1 FROM scopes s, deployment d WHERE s.scope_id = NEW.scope_id + AND (s.association_count >= s.association_limit OR d.association_count >= d.association_limit) + ) THEN RAISE(ABORT, 'cache_association_limit') END); + UPDATE scopes SET association_count = association_count + 1 WHERE scope_id = NEW.scope_id; + UPDATE deployment SET association_count = association_count + 1; +END; +CREATE TRIGGER uncount_association AFTER DELETE ON associations BEGIN + UPDATE scopes SET association_count = association_count - 1 WHERE scope_id = OLD.scope_id; + UPDATE deployment SET association_count = association_count - 1; +END; + +-- All publication mutations inherit the guarded state change's transaction. +-- A zero-row guard fires no trigger. A quota failure rolls every mutation back. +CREATE TRIGGER publish_generation AFTER UPDATE OF state ON generations +WHEN OLD.state = 'uploading' AND NEW.state = 'ready' +BEGIN + UPDATE generations SET state = 'retired', retired_at = unixepoch(), + gc_after = (CASE WHEN expires_at > unixepoch() THEN unixepoch() + 600 ELSE unixepoch() END) + WHERE generation_id = (SELECT generation_id FROM entries WHERE scope_id = NEW.scope_id AND key = NEW.key) AND state = 'ready'; + INSERT INTO entries (scope_id, key, generation_id) VALUES (NEW.scope_id, NEW.key, NEW.generation_id) + ON CONFLICT (scope_id, key) DO UPDATE SET generation_id = excluded.generation_id; + INSERT INTO associations (scope_id, secondary_key, target_key) VALUES (NEW.scope_id, NEW.secondary_key, NEW.key) + ON CONFLICT (scope_id, secondary_key) DO UPDATE SET target_key = excluded.target_key; +END; diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0002_pull_requests.sql b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0002_pull_requests.sql new file mode 100644 index 000000000000..86418a278783 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0002_pull_requests.sql @@ -0,0 +1,12 @@ +-- Per-namespace opt-in for uploads from pull_request runs whose base is the registered branch. +-- Off by default: such uploads run unreviewed PR code, and default-branch builds restore their entries. +ALTER TABLE scopes ADD COLUMN pull_requests INTEGER NOT NULL DEFAULT 0 CHECK (pull_requests IN (0, 1)); + +-- Turning the opt-in off must also invalidate uploads that captured the previous policy. +DROP TRIGGER changed_policy; +CREATE TRIGGER changed_policy AFTER UPDATE OF endpoint, repository_id, repository_owner_id, branch, + enabled, writes_enabled, pull_requests, retention_seconds, byte_limit, entry_limit, association_limit ON scopes +WHEN NEW.policy_version = OLD.policy_version +BEGIN + UPDATE scopes SET policy_version = OLD.policy_version + 1 WHERE scope_id = NEW.scope_id; +END; diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/package.json b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/package.json new file mode 100644 index 000000000000..3f6f180f87cf --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/package.json @@ -0,0 +1,10 @@ +{ + "name": "@voidzero-dev/vite-plus-remote-cache-cloudflare", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "dependencies": { + "jose": "catalog:" + } +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/admission.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/admission.ts new file mode 100644 index 000000000000..0de93ba47bed --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/admission.ts @@ -0,0 +1,17 @@ +import { HttpError } from './errors.ts'; + +// Isolate-wide resource counters contain no request data or I/O promises. Rate +// limiting controls traffic; these counters additionally bound simultaneous buffers. +export class Admission { + private stores = 0; + private reads = 0; + acquire(store: boolean): () => void { + if (store ? this.stores >= 2 : this.reads >= 4) throw new HttpError(503, 'concurrency_limit'); + if (store) this.stores++; + else this.reads++; + return () => { + if (store) this.stores--; + else this.reads--; + }; + } +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/auth.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/auth.ts new file mode 100644 index 000000000000..1e12addf0b64 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/auth.ts @@ -0,0 +1,166 @@ +// @effect-diagnostics globalDate:off globalFetch:off - vendored upstream protocol, not an Effect service. +import { + createRemoteJWKSet, + customFetch, + decodeProtectedHeader, + errors, + jwtVerify, + type JWTVerifyGetKey, + type JWTPayload, +} from 'jose'; +import { HttpError, unavailable } from './errors.ts'; +import { Deadline, readBody } from './streams.ts'; +import type { Scope } from './database.ts'; + +export const ISSUER = 'https://token.actions.githubusercontent.com'; +export const JWKS_URL = `${ISSUER}/.well-known/jwks`; + +export function githubKeys(): JWTVerifyGetKey { + // Only reusable public-key cache state lives across requests. Failed refreshes + // also have a cooldown; jose's own cooldown starts only after a successful fetch. + let lastAttempt = -Infinity; + return createRemoteJWKSet(new URL(JWKS_URL), { + timeoutDuration: 5000, + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + [customFetch]: async (url, options) => { + if (Date.now() - lastAttempt < 30_000) unavailable(); + lastAttempt = Date.now(); + const deadline = new Deadline(5000, options.signal); + try { + const response = await deadline.run(fetch(url, options)); + if (response.status !== 200) { + void response.body?.cancel(); + unavailable(); + } + const bytes = await readBody(response.body, 64 * 1024, deadline); + return new Response(bytes, { headers: { 'Content-Type': 'application/json' } }); + } catch { + unavailable(); + } finally { + deadline.dispose(); + } + }, + }); +} + +export interface WriteIdentity { + exp: number; + repository_id: string; + workflow_ref?: string; + run_id?: string; + run_attempt?: string; + sha?: string; + event_name?: string; + head_ref?: string; +} + +export async function authorize( + request: Request, + scope: Scope, + keys: JWTVerifyGetKey, +): Promise { + const authorization = request.headers.get('Authorization'); + if ( + !authorization || + authorization.length > 16 * 1024 || + !/^Bearer [A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/i.test(authorization) + ) { + throw new HttpError(401, 'invalid_token'); + } + const token = authorization.slice(7); + let payload: JWTPayload; + try { + const header = decodeProtectedHeader(token); + if ( + header.alg !== 'RS256' || + typeof header.kid !== 'string' || + header.kid.length > 256 || + header.jku || + header.x5u || + header.jwk + ) { + throw new HttpError(401, 'invalid_token'); + } + ({ payload } = await jwtVerify(token, keys, { + algorithms: ['RS256'], + issuer: ISSUER, + requiredClaims: ['exp', 'nbf', 'iat'], + clockTolerance: 30, + maxTokenAge: '15 minutes', + })); + } catch (error) { + if (error instanceof HttpError) throw error; + if ( + error instanceof errors.JWTExpired || + error instanceof errors.JWTClaimValidationFailed || + error instanceof errors.JWSSignatureVerificationFailed || + error instanceof errors.JWSInvalid || + error instanceof errors.JWTInvalid || + error instanceof errors.JOSENotSupported || + error instanceof errors.JWKSNoMatchingKey || + error instanceof errors.JOSEAlgNotAllowed + ) { + throw new HttpError(401, 'invalid_token'); + } + unavailable(); + } + const now = Date.now() / 1000; + const { exp, nbf, iat } = payload; + if ( + typeof exp !== 'number' || + typeof nbf !== 'number' || + typeof iat !== 'number' || + !Number.isSafeInteger(exp) || + !Number.isSafeInteger(nbf) || + !Number.isSafeInteger(iat) || + exp <= now || + nbf > now + 30 || + iat > now + 30 || + iat < now - 930 || + nbf > exp || + iat >= exp || + exp - iat > 900 + ) { + throw new HttpError(401, 'invalid_token'); + } + // A `push` must update the registered branch. A namespace that opts in also accepts + // `pull_request` runs whose PR targets that branch. For those, GitHub signs `ref` + // `refs/pull//merge`, `ref_type` `branch`, and the target branch's short name (`main`) + // in `base_ref`. The `closed` run of a merged PR has the base branch in `ref` and is denied. + // Fork PR runs carry the base repository's IDs too; only GitHub withholding `id-token: write` + // from them keeps them out. + const event = + payload.event_name === 'push' + ? payload.ref === scope.branch + : payload.event_name === 'pull_request' && + scope.pull_requests === 1 && + typeof payload.ref === 'string' && + /^refs\/pull\/[1-9][0-9]*\/merge$/.test(payload.ref) && + typeof payload.base_ref === 'string' && + `refs/heads/${payload.base_ref}` === scope.branch; + if ( + !scope.writes_enabled || + payload.aud !== scope.endpoint || + payload.repository_id !== scope.repository_id || + payload.repository_owner_id !== scope.repository_owner_id || + payload.repository_visibility !== 'public' || + payload.ref_type !== 'branch' || + !event + ) { + throw new HttpError(403, 'write_policy'); + } + const identity: WriteIdentity = { exp, repository_id: scope.repository_id }; + for (const name of [ + 'workflow_ref', + 'run_id', + 'run_attempt', + 'sha', + 'event_name', + 'head_ref', + ] as const) { + const value = payload[name]; + if (typeof value === 'string' && value && value.length <= 512) identity[name] = value; + } + return identity; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts new file mode 100644 index 000000000000..08510e39453b --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts @@ -0,0 +1,151 @@ +import { badRequest, tooLarge } from './errors.ts'; +import type { Limits } from './limits.ts'; + +export interface FetchRequest { + key: Uint8Array; + secondary_key: Uint8Array; +} + +export interface StoreMetadata extends FetchRequest { + value: Uint8Array; +} + +// This codec only decodes the protocol envelope. Opaque bytes are never decoded. +// The accepted containers are one map and (possibly chunked) strings: depth <= 2. +class Decoder { + offset = 0; + constructor(private data: Uint8Array) {} + byte(): number { + return this.data[this.offset++] ?? badRequest(); + } + head(major: number): number | null { + const first = this.byte(); + if (first >> 5 !== major) badRequest(); + const info = first & 31; + if (info < 24) return info; + if (info === 31) return null; + if (info > 27) badRequest(); + let length = 0; + for (let i = 0; i < 2 ** (info - 24); i++) length = length * 256 + this.byte(); + if (!Number.isSafeInteger(length)) tooLarge(); + return length; + } + string(major: number, limit: number): Uint8Array { + const length = this.head(major); + if (length !== null) return this.take(length, limit); + const chunks: Uint8Array[] = []; + let size = 0; + while (this.data[this.offset] !== 255) { + const chunkLength = this.head(major); + if (chunkLength === null) badRequest(); + size += chunkLength; + if (size > limit) tooLarge(); + chunks.push(this.take(chunkLength, limit)); + // Bound bookkeeping even for a malicious sequence of empty chunks. + if (chunks.length > 4096) tooLarge(); + } + this.offset++; + return join(chunks, size); + } + take(size: number, limit: number): Uint8Array { + if (size > limit) tooLarge(); + if (size > this.data.length - this.offset) badRequest(); + const value = this.data.subarray(this.offset, this.offset + size); + this.offset += size; + return value; + } + finished(): boolean { + return this.offset === this.data.length; + } + break(): boolean { + if (this.data[this.offset] !== 255) return false; + this.offset++; + return true; + } +} + +export function join(chunks: Uint8Array[], size: number): Uint8Array { + const data = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + data.set(chunk, offset); + offset += chunk.length; + } + return data; +} + +export function decodeEnvelope(data: Uint8Array, store: false, limits: Limits): FetchRequest; +export function decodeEnvelope(data: Uint8Array, store: true, limits: Limits): StoreMetadata; +export function decodeEnvelope( + data: Uint8Array, + store: boolean, + limits: Limits, +): FetchRequest | StoreMetadata { + const decoder = new Decoder(data); + const count = decoder.head(5); + const expected = store ? 3 : 2; + if (count !== null && count !== expected) badRequest(); + const fields = new Map(); + for (let i = 0; i < expected; i++) { + let name: string; + try { + name = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode( + decoder.string(3, 32), + ); + } catch { + badRequest(); + } + const allowed = name === 'key' || name === 'secondary_key' || (store && name === 'value'); + if (!allowed || fields.has(name)) badRequest(); + fields.set(name, decoder.string(2, name === 'value' ? limits.value : limits.key)); + } + if ((count === null && !decoder.break()) || !decoder.finished()) badRequest(); + return { + key: fields.get('key')!, + secondary_key: fields.get('secondary_key')!, + ...(store ? { value: fields.get('value')! } : {}), + }; +} + +function header(major: number, length: number): Uint8Array { + if (length < 24) return Uint8Array.of((major << 5) | length); + if (length <= 255) return Uint8Array.of((major << 5) | 24, length); + if (length <= 65535) return Uint8Array.of((major << 5) | 25, length >> 8, length & 255); + return Uint8Array.of( + (major << 5) | 26, + length >>> 24, + (length >>> 16) & 255, + (length >>> 8) & 255, + length & 255, + ); +} + +export function encodeEnvelope( + fields: Record, +): Uint8Array { + const chunks = [header(5, Object.keys(fields).length)]; + const string = (value: string | Uint8Array) => { + const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value; + chunks.push(header(typeof value === 'string' ? 3 : 2, bytes.length), bytes); + }; + for (const [key, value] of Object.entries(fields)) { + string(key); + if (value === null) chunks.push(Uint8Array.of(246)); + else string(value); + } + return join( + chunks, + chunks.reduce((sum, chunk) => sum + chunk.length, 0), + ); +} + +export function cborResponse(fields: Record): Response { + const body = encodeEnvelope(fields); + return new Response(body, { + headers: { + 'Content-Type': 'application/cbor', + 'Content-Length': String(body.length), + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/database.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/database.ts new file mode 100644 index 000000000000..a46dc43e5474 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/database.ts @@ -0,0 +1,291 @@ +// @effect-diagnostics cryptoRandomUUID:off globalConsole:off - vendored upstream protocol, not an Effect service. +import { HttpError, unavailable } from './errors.ts'; +import { GRACE_SECONDS, LEASE_SECONDS } from './limits.ts'; +import { measured, type Observations } from './observations.ts'; + +export interface Scope { + scope_id: string; + endpoint: string; + repository_id: string; + repository_owner_id: string; + branch: string; + policy_version: number; + writes_enabled: number; + // 1 when `pull_request` runs whose PR targets `branch` may also publish. + pull_requests: number; +} +export interface Generation { + generation_id: string; + scope_id: string; + value_object: string; + blob_object: string; + blob_id: string | null; + value_size: number; + blob_size: number; +} +export interface Selection extends Generation { + kind: 'exact' | 'fallback'; + key: number[]; +} + +// Preserve empty and non-UTF-8 keys as BLOBs, without text or hash conversions. +export function binary(bytes: Uint8Array): ArrayBuffer { + return bytes.slice().buffer; +} + +export async function getScope(db: D1Database, id: string, stats?: Observations): Promise { + let scope: Scope | null; + try { + const { results } = await measured( + db + .prepare(`SELECT s.*, (s.writes_enabled AND d.writes_enabled) AS writes_enabled + FROM scopes s, deployment d WHERE s.scope_id = ? AND s.enabled = 1 AND d.enabled = 1`) + .bind(id) + .all(), + stats, + ); + scope = results[0] ?? null; + } catch { + unavailable(); + } + if (!scope) throw new HttpError(404, 'unknown_scope'); + return scope; +} + +export async function selectEntry( + db: D1Database, + scope: string, + key: Uint8Array, + secondary: Uint8Array, + stats?: Observations, +): Promise { + // Both branches use indexed identities in one SQLite snapshot. + try { + const { results } = await measured( + db + .prepare(` + SELECT g.*, e.key, 'exact' AS kind, 0 AS priority + FROM entries e JOIN generations g ON g.generation_id = e.generation_id + JOIN scopes s ON s.scope_id = e.scope_id CROSS JOIN deployment d + WHERE e.scope_id = ?1 AND e.key = ?2 AND g.state = 'ready' AND g.expires_at > unixepoch() + AND s.enabled = 1 AND d.enabled = 1 + UNION ALL + SELECT g.*, e.key, 'fallback' AS kind, 1 AS priority + FROM associations a JOIN entries e ON e.scope_id = a.scope_id AND e.key = a.target_key + JOIN generations g ON g.generation_id = e.generation_id + JOIN scopes s ON s.scope_id = e.scope_id CROSS JOIN deployment d + WHERE a.scope_id = ?1 AND a.secondary_key = ?3 AND g.state = 'ready' AND g.expires_at > unixepoch() + AND s.enabled = 1 AND d.enabled = 1 + ORDER BY priority LIMIT 1`) + .bind(scope, binary(key), binary(secondary)) + .all(), + stats, + ); + return results[0] ?? null; + } catch { + unavailable(); + } +} + +export async function selectBlob( + db: D1Database, + scope: string, + blob: string, + stats?: Observations, +): Promise { + try { + const { results } = await measured( + db + .prepare(`SELECT g.* FROM generations g JOIN scopes s ON s.scope_id = g.scope_id CROSS JOIN deployment d + WHERE g.scope_id = ? AND g.blob_id = ? AND s.enabled = 1 AND d.enabled = 1 + AND ((g.state = 'ready' AND g.expires_at > unixepoch()) OR (g.state = 'retired' AND g.gc_after > unixepoch()))`) + .bind(scope, blob) + .all(), + stats, + ); + return results[0] ?? null; + } catch { + unavailable(); + } +} + +export async function reserve( + db: D1Database, + scope: Scope, + tokenExp: number, + bytes: number, + stats?: Observations, +): Promise { + const id = crypto.randomUUID(); + const prefix = `${scope.scope_id}/${id}`; + try { + await measured( + db + .prepare(`INSERT INTO generations + (generation_id, scope_id, state, policy_version, token_exp, lease_until, gc_after, value_object, blob_object, charged_bytes) + VALUES (?, ?, 'uploading', ?, ?, unixepoch() + ?, unixepoch() + ?, ?, ?, ?)`) + .bind( + id, + scope.scope_id, + scope.policy_version, + tokenExp, + LEASE_SECONDS, + LEASE_SECONDS + GRACE_SECONDS, + `${prefix}/value`, + `${prefix}/blob`, + bytes, + ) + .run(), + stats, + ); + } catch { + unavailable(); + } + return { + generation_id: id, + scope_id: scope.scope_id, + value_object: `${prefix}/value`, + blob_object: `${prefix}/blob`, + blob_id: null, + value_size: 0, + blob_size: 0, + }; +} + +export async function recordMultipart( + db: D1Database, + generation: Generation, + uploadId: string, + stats?: Observations, +): Promise { + const result = await measured( + db + .prepare( + `UPDATE generations SET upload_id = ? WHERE generation_id = ? AND state = 'uploading' AND lease_until > unixepoch()`, + ) + .bind(uploadId, generation.generation_id) + .run(), + stats, + ); + if (result.meta.changes !== 1) unavailable(); +} + +export async function publish( + db: D1Database, + generation: Generation, + key: Uint8Array, + secondary: Uint8Array, + stats?: Observations, +): Promise { + const actual = generation.value_size + generation.blob_size; + let results: D1Result[]; + try { + results = await measured( + db.batch([ + db + .prepare(`UPDATE generations SET state = 'ready', key = ?, secondary_key = ?, blob_id = ?, + value_size = ?, blob_size = ?, charged_bytes = ?, + expires_at = unixepoch() + (SELECT retention_seconds FROM scopes WHERE scope_id = generations.scope_id), + gc_after = unixepoch() + (SELECT retention_seconds FROM scopes WHERE scope_id = generations.scope_id) + WHERE generation_id = ? AND state = 'uploading' AND lease_until > unixepoch() AND token_exp > unixepoch() + AND charged_bytes >= ? AND EXISTS ( + SELECT 1 FROM scopes s, deployment d WHERE s.scope_id = generations.scope_id + AND s.policy_version = generations.policy_version AND s.enabled = 1 AND s.writes_enabled = 1 + AND d.enabled = 1 AND d.writes_enabled = 1 AND s.charged_bytes <= s.byte_limit AND d.charged_bytes <= d.byte_limit + ) RETURNING generation_id`) + .bind( + binary(key), + binary(secondary), + generation.blob_id, + generation.value_size, + generation.blob_size, + actual, + generation.generation_id, + actual, + ), + ]), + stats, + ); + } catch { + unavailable(); + } + if (results[0]?.results.length !== 1) throw new HttpError(503, 'publication_guard'); +} + +export async function abandon(db: D1Database, id: string): Promise { + // Keep charges during the grace period for R2 operations that finish late. + await db + .prepare(`UPDATE generations SET lease_until = min(lease_until, unixepoch()), gc_after = min(gc_after, unixepoch() + ?) + WHERE generation_id = ? AND state = 'uploading'`) + .bind(GRACE_SECONDS, id) + .run(); +} + +export async function cleanup(env: Env): Promise { + const limit = Number(env.GC_BATCH_SIZE); + if (!Number.isInteger(limit) || limit < 1 || limit > 256) + throw new Error('Invalid GC batch size'); + const claim = crypto.randomUUID(); + const claimed = + await env.INDEX.prepare(`UPDATE generations SET state = 'deleting', gc_claim = ?, gc_after = unixepoch() + 600 + WHERE generation_id IN (SELECT generation_id FROM generations WHERE gc_after <= unixepoch() ORDER BY gc_after LIMIT ?) + RETURNING generation_id, value_object, blob_object, upload_id`) + .bind(claim, limit) + .all<{ + generation_id: string; + value_object: string; + blob_object: string; + upload_id: string | null; + }>(); + let deleted = 0; + for (const generation of claimed.results) { + try { + if (generation.upload_id) + await env.ARTIFACTS.resumeMultipartUpload( + generation.blob_object, + generation.upload_id, + ).abort(); + await env.ARTIFACTS.delete([generation.value_object, generation.blob_object]); + // FK deletion only removes entries still selecting this generation. + await env.INDEX.prepare( + `DELETE FROM generations WHERE generation_id = ? AND state = 'deleting' AND gc_claim = ?`, + ) + .bind(generation.generation_id, claim) + .run(); + deleted++; + } catch { + console.warn( + JSON.stringify({ + operation: 'cleanup', + error: 'deletion_failed', + generation: generation.generation_id, + }), + ); + } + } + await cleanAssociations(env.INDEX, limit); + console.log(JSON.stringify({ operation: 'cleanup', claimed: claimed.results.length, deleted })); +} + +async function cleanAssociations(db: D1Database, limit: number): Promise { + // Cursor bounds scanning as well as deletion, even when all associations are live. + const batch = await db + .prepare(`SELECT scope_id, secondary_key FROM associations + WHERE (scope_id, secondary_key) > (SELECT scope_id, secondary_key FROM maintenance WHERE id = 1) + ORDER BY scope_id, secondary_key LIMIT ?`) + .bind(limit) + .all<{ scope_id: string; secondary_key: number[] }>(); + const last = batch.results.at(-1); + await db.batch([ + db + .prepare(`DELETE FROM associations WHERE (scope_id, secondary_key) IN ( + SELECT scope_id, secondary_key FROM associations + WHERE (scope_id, secondary_key) > (SELECT scope_id, secondary_key FROM maintenance WHERE id = 1) + ORDER BY scope_id, secondary_key LIMIT ?) + AND NOT EXISTS (SELECT 1 FROM entries e WHERE e.scope_id = associations.scope_id AND e.key = associations.target_key)`) + .bind(limit), + db + .prepare('UPDATE maintenance SET scope_id = ?, secondary_key = ? WHERE id = 1') + .bind(last?.scope_id ?? '', binary(new Uint8Array(last?.secondary_key ?? []))), + ]); +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/errors.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/errors.ts new file mode 100644 index 000000000000..9b54ffce50c8 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/errors.ts @@ -0,0 +1,42 @@ +export class HttpError extends Error { + constructor( + public status: number, + public code: string, + ) { + super(code); + } +} + +export function badRequest(): never { + throw new HttpError(400, 'invalid_request'); +} + +export function tooLarge(): never { + throw new HttpError(413, 'size_limit'); +} + +export function unavailable(): never { + throw new HttpError(503, 'unavailable'); +} + +export function errorResponse(error: unknown): Response { + const status = error instanceof HttpError ? error.status : 500; + const messages: Record = { + 400: 'Invalid request', + 401: 'Invalid credentials', + 403: 'Write not permitted', + 404: 'Not found', + 413: 'Request too large', + 429: 'Rate limit exceeded', + 500: 'Operation failed', + 503: 'Service unavailable', + }; + return new Response(messages[status] ?? messages[500], { + status, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + ...(status === 429 || status === 503 ? { 'Retry-After': '60' } : {}), + }, + }); +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts new file mode 100644 index 000000000000..73b8283e227e --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts @@ -0,0 +1,42 @@ +import { decodeEnvelope } from './cbor.ts'; +import { selectEntry } from './database.ts'; +import { badRequest, HttpError, unavailable } from './errors.ts'; +import type { Limits } from './limits.ts'; +import { parameters } from './multipart.ts'; +import type { Observations } from './observations.ts'; +import { contentLength, Deadline, readBody } from './streams.ts'; + +type FetchResult = + | { kind: 'exact'; value: Uint8Array; blob_id: string | null } + | { kind: 'fallback'; key: Uint8Array }; + +export async function fetchMetadata( + request: Request, + env: Pick, + scope: string, + limits: Limits, + deadline: Deadline, + stats: Observations, +): Promise { + if (parameters(request.headers.get('Content-Type') ?? '').type !== 'application/cbor') + badRequest(); + contentLength(request, limits.fetch); + const body = await readBody(request.body, limits.fetch, deadline); + stats.request_bytes = body.length; + const data = decodeEnvelope(body, false, limits); + const selected = await deadline.run( + selectEntry(env.INDEX, scope, data.key, data.secondary_key, stats), + ); + if (!selected) throw new HttpError(404, 'miss'); + // Fallbacks only identify the associated key. They must not depend on R2 availability. + if (selected.kind === 'fallback') return { kind: 'fallback', key: new Uint8Array(selected.key) }; + + stats.r2_operations++; + const object = await deadline + .run(env.ARTIFACTS.get(selected.value_object)) + .catch(() => unavailable()); + if (!object || object.size !== selected.value_size || object.size > limits.value) unavailable(); + const value = await readBody(object.body, limits.value, deadline).catch(() => unavailable()); + if (value.length !== selected.value_size) unavailable(); + return { kind: 'exact', value, blob_id: selected.blob_id }; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/index.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/index.ts new file mode 100644 index 000000000000..5b7c0cb9c64b --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/index.ts @@ -0,0 +1,130 @@ +import { authorize, githubKeys, type WriteIdentity } from './auth.ts'; +import { cborResponse } from './cbor.ts'; +import { cleanup, getScope, selectBlob } from './database.ts'; +import { errorResponse, HttpError, unavailable } from './errors.ts'; +import { fetchMetadata } from './fetch.ts'; +import { limitsFrom } from './limits.ts'; +import { store } from './store.ts'; +import { Deadline } from './streams.ts'; +import { Admission } from './admission.ts'; +import { Observations } from './observations.ts'; + +const keys = githubKeys(); +const admission = new Admission(); + +export default { + async fetch(request, env, ctx) { + const started = Date.now(); + const id = crypto.randomUUID(); + const stats = new Observations(); + let scopeId = 'unknown'; + let operation = 'unknown'; + let outcome = 'error'; + let identity: WriteIdentity | undefined; + let deadline: Deadline | undefined; + let release: (() => void) | undefined; + let response: Response; + try { + const limits = limitsFrom(env.LIMITS); + const url = new URL(request.url); + deadline = new Deadline( + url.pathname.endsWith('/store') ? limits.deadlineMs : Math.min(limits.deadlineMs, 15000), + request.signal, + ); + const route = + /^\/projects\/([a-z0-9][a-z0-9-]{0,62})\/(fetch|store|blob\/([0-9a-f-]{36}))$/.exec( + url.pathname, + ); + const namespaces: unknown = JSON.parse(env.NAMESPACES); + if ( + !Array.isArray(namespaces) || + namespaces.length > 100 || + namespaces.some((value) => typeof value !== 'string') + ) + throw new Error('Invalid namespaces'); + const known = route && namespaces.includes(route[1]); + if (known) { + scopeId = route[1]!; + operation = route[2]!.startsWith('blob/') ? 'blob' : route[2]!; + } + const limiter = + operation === 'store' || url.pathname.endsWith('/store') + ? env.STORE_LIMITER + : env.READ_LIMITER; + const rateLimit = await deadline.run( + limiter.limit({ key: known ? `${scopeId}:${operation}` : 'unknown' }), + ); + if (!rateLimit.success) throw new HttpError(429, 'rate_limit'); + if (!known || url.search || request.method !== (operation === 'blob' ? 'GET' : 'POST')) + throw new HttpError(404, 'unknown_route'); + release = admission.acquire(operation === 'store'); + const scope = await deadline.run(getScope(env.INDEX, scopeId, stats)); + if (operation === 'store') { + identity = await deadline.run(authorize(request, scope, keys)); + response = await store(request, env, ctx, scope, identity, limits, deadline, stats); + outcome = 'stored'; + } else if (operation === 'fetch') { + const result = await fetchMetadata(request, env, scopeId, limits, deadline, stats); + response = cborResponse(result); + outcome = result.kind; + } else { + const selected = await deadline.run(selectBlob(env.INDEX, scopeId, route[3]!, stats)); + if (!selected) throw new HttpError(404, 'blob_missing'); + stats.r2_operations++; + const object = await deadline + .run(env.ARTIFACTS.get(selected.blob_object)) + .catch(() => unavailable()); + if (!object) throw new HttpError(404, 'blob_missing'); + if (object.size !== selected.blob_size) unavailable(); + response = new Response(object.body, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(object.size), + 'Cache-Control': 'no-store', + }, + }); + outcome = 'blob'; + } + } catch (error) { + response = errorResponse(error); + if (error instanceof HttpError) outcome = error.code; + } finally { + deadline?.dispose(); + release?.(); + } + response.headers.set('X-Request-Id', id); + response.headers.set('X-Remote-Cache-Deployment', env.DEPLOYMENT_ID ?? 'local'); + const sample = Math.min(1, Math.max(0, Number(env.LOG_SAMPLE_RATE) || 0)); + // Fixed sampling bounds error volume as well as successful request volume. + if (Math.random() < sample) + console.log( + JSON.stringify({ + request_id: id, + scope: scopeId, + operation, + status: response.status, + outcome, + ...(response.status >= 400 ? { error_class: outcome } : {}), + response_bytes: Number(response.headers.get('Content-Length') ?? 0), + duration_ms: Date.now() - started, + ...stats.toJSON(), + ...(stats.d1_storage_bytes >= 400000000 ? { warning: 'd1_storage_400mb' } : {}), + ...(identity + ? { + repository_id: identity.repository_id, + workflow_ref: identity.workflow_ref, + run_id: identity.run_id, + run_attempt: identity.run_attempt, + sha: identity.sha, + event_name: identity.event_name, + head_ref: identity.head_ref, + } + : {}), + }), + ); + return response; + }, + async scheduled(_event, env) { + await cleanup(env); + }, +} satisfies ExportedHandler; diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/limits.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/limits.ts new file mode 100644 index 000000000000..b3c1f2650f53 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/limits.ts @@ -0,0 +1,42 @@ +export const MiB = 1024 * 1024; +export const PART_SIZE = 5 * MiB; +export const LEASE_SECONDS = 15 * 60; +export const GRACE_SECONDS = 10 * 60; + +export const defaults = { + key: 16 * 1024, + value: 4 * MiB, + metadata: 5 * MiB, + fetch: 40 * 1024, + blob: 64 * MiB, + store: 72 * MiB, + headers: 8 * 1024, + deadlineMs: 120_000, +}; +export type Limits = typeof defaults; + +export function limitsFrom(raw: string): Limits { + const input: unknown = JSON.parse(raw); + if (!input || typeof input !== 'object' || Array.isArray(input)) + throw new Error('Invalid limits'); + const result = { ...defaults }; + for (const [name, value] of Object.entries(input)) { + if ( + !(name in defaults) || + !Number.isSafeInteger(value) || + value <= 0 || + value > defaults[name as keyof Limits] + ) { + throw new Error('Limits must be positive integers no larger than the tested defaults'); + } + result[name as keyof Limits] = value; + } + if ( + result.metadata < 2 * result.key + result.value + 128 || + result.fetch < 2 * result.key + 128 || + result.store < result.metadata + result.blob + 1024 + ) { + throw new Error('Envelope limits must accommodate field limits and framing'); + } + return result; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/multipart.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/multipart.ts new file mode 100644 index 000000000000..cacbdd672216 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/multipart.ts @@ -0,0 +1,120 @@ +import { Buffer } from 'node:buffer'; +import { badRequest, tooLarge } from './errors.ts'; +import { collect, Input } from './streams.ts'; + +export function parameters(raw: string): { type: string; params: Map } { + const separator = raw.indexOf(';'); + const type = (separator === -1 ? raw : raw.slice(0, separator)).trim().toLowerCase(); + let rest = separator === -1 ? '' : raw.slice(separator); + const params = new Map(); + while (rest.length) { + const match = /^;\s*([\w-]+)\s*=\s*(?:"((?:[^"\\\r\n]|\\[^\r\n])*)"|([^\s;]+))\s*/.exec(rest); + if (!match) badRequest(); + const name = match[1]!.toLowerCase(); + if (params.has(name)) badRequest(); + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3]!); + rest = rest.slice(match[0].length); + } + return { type, params }; +} + +export function boundaryFrom(contentType: string | null): string { + const { type, params } = parameters(contentType ?? ''); + const boundary = params.get('boundary'); + if ( + type !== 'multipart/form-data' || + !boundary || + boundary.length > 70 || + !/^[0-9A-Za-z'()+_,./:=? -]+$/.test(boundary) || + boundary.endsWith(' ') + ) + badRequest(); + return boundary; +} + +export async function* multipart( + input: Input, + boundary: string, + headerLimit: number, +): AsyncGenerator<{ + name: 'metadata' | 'blob'; + body: AsyncGenerator; +}> { + let preamble = 0; + while (true) { + const line = await input.until(Buffer.from('\r\n'), headerLimit); + preamble += line.length + 2; + if (preamble > headerLimit) tooLarge(); + if (line.toString('utf8').replace(/[ \t]+$/, '') === `--${boundary}`) break; + } + const delimiter = Buffer.from(`\r\n--${boundary}`); + const seen = new Set(); + let closed = false; + while (!closed) { + if (seen.size >= 2) badRequest(); + const raw = await input.until(Buffer.from('\r\n\r\n'), headerLimit); + const headers = new Map(); + for (const line of raw.toString('utf8').split('\r\n')) { + const match = /^([!#$%&'*+.^_`|~\w-]+):[ \t]*([^\r\n]*)$/.exec(line); + if (!match) badRequest(); + const name = match[1]!.toLowerCase(); + if (headers.has(name)) badRequest(); + headers.set(name, match[2]!.trim()); + } + const disposition = parameters(headers.get('content-disposition') ?? ''); + const name = disposition.params.get('name'); + if ( + disposition.type !== 'form-data' || + (name !== 'metadata' && name !== 'blob') || + seen.has(name) + ) + badRequest(); + if (headers.has('content-transfer-encoding')) badRequest(); + const expected = name === 'metadata' ? 'application/cbor' : 'application/octet-stream'; + if (parameters(headers.get('content-type') ?? '').type !== expected) badRequest(); + seen.add(name); + let consumed = false; + async function* body(): AsyncGenerator { + while (true) { + let index = input.buffer.indexOf(delimiter); + while (index >= 0) { + const match = await delimiterEnd(input, index + delimiter.length, headerLimit); + if (match) { + if (index) yield input.take(index); + input.take(match.end - index); + closed = match.closed; + consumed = true; + return; + } + index = input.buffer.indexOf(delimiter, index + 1); + } + const safe = input.buffer.length - delimiter.length - 2; + if (safe > 0) yield input.take(safe); + if (!(await input.fill())) badRequest(); + } + } + yield { name, body: body() }; + if (!consumed) throw new Error('Multipart consumer must drain each part'); + } + if (!seen.has('metadata')) badRequest(); + await collect(input.rest(), headerLimit); +} + +async function delimiterEnd( + input: Input, + start: number, + limit: number, +): Promise<{ end: number; closed: boolean } | null> { + while (input.buffer.length < start + 2 && (await input.fill())) {} + const closed = input.buffer[start] === 45 && input.buffer[start + 1] === 45; + let end = start + (closed ? 2 : 0); + while (true) { + while (input.buffer.length < end + 2 && (await input.fill())) {} + if (input.buffer[end] !== 32 && input.buffer[end] !== 9) break; + if (++end - start > limit) tooLarge(); + } + if (input.buffer[end] === 13 && input.buffer[end + 1] === 10) return { end: end + 2, closed }; + if (closed && input.ended && end === input.buffer.length) return { end, closed }; + // A boundary prefix followed by arbitrary bytes is still part of the opaque body. + return null; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/observations.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/observations.ts new file mode 100644 index 000000000000..5540748cb370 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/observations.ts @@ -0,0 +1,42 @@ +// @effect-diagnostics globalDate:off - vendored upstream protocol, not an Effect service. +export class Observations { + request_bytes = 0; + d1_rows_read = 0; + d1_rows_written = 0; + d1_ms = 0; + d1_queries = 0; + d1_storage_bytes = 0; + r2_operations = 0; + + toJSON() { + return { + request_bytes: this.request_bytes, + d1_rows_read: this.d1_rows_read, + d1_rows_written: this.d1_rows_written, + d1_ms: this.d1_ms, + d1_queries: this.d1_queries, + d1_storage_bytes: this.d1_storage_bytes, + r2_operations: this.r2_operations, + }; + } +} + +export async function measured( + operation: Promise, + stats?: Observations, +): Promise { + const start = Date.now(); + try { + const result = await operation; + if (stats) + for (const item of Array.isArray(result) ? result : [result]) { + stats.d1_rows_read += item.meta.rows_read; + stats.d1_rows_written += item.meta.rows_written; + stats.d1_storage_bytes = item.meta.size_after; + stats.d1_queries++; + } + return result; + } finally { + if (stats) stats.d1_ms += Date.now() - start; + } +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/store.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/store.ts new file mode 100644 index 000000000000..0e73ecf8054e --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/store.ts @@ -0,0 +1,131 @@ +// @effect-diagnostics cryptoRandomUUID:off globalConsole:off - vendored upstream protocol, not an Effect service. +import { cborResponse, decodeEnvelope, type StoreMetadata } from './cbor.ts'; +import { + abandon, + publish, + recordMultipart, + reserve, + type Generation, + type Scope, +} from './database.ts'; +import { badRequest, tooLarge, unavailable } from './errors.ts'; +import { PART_SIZE, type Limits } from './limits.ts'; +import { boundaryFrom, multipart } from './multipart.ts'; +import { collect, contentLength, Deadline, Input } from './streams.ts'; +import type { WriteIdentity } from './auth.ts'; +import type { Observations } from './observations.ts'; + +async function uploadBlob( + env: Env, + generation: Generation, + source: AsyncIterable, + limit: number, + deadline: Deadline, + stats: Observations, +): Promise { + let buffer = new Uint8Array(PART_SIZE); + let used = 0; + let upload: R2MultipartUpload | undefined; + const parts: R2UploadedPart[] = []; + for await (const chunk of source) { + generation.blob_size += chunk.length; + if (generation.blob_size > limit) tooLarge(); + let offset = 0; + while (offset < chunk.length) { + if (used === PART_SIZE) { + if (!upload) { + stats.r2_operations++; + upload = await deadline.run(env.ARTIFACTS.createMultipartUpload(generation.blob_object)); + try { + await deadline.run(recordMultipart(env.INDEX, generation, upload.uploadId, stats)); + } catch (error) { + // Also cover creation succeeding but D1 failing to record its ID. + try { + await deadline.run(upload.abort()); + } catch { + /* Lifecycle is the final backstop. */ + } + throw error; + } + } + stats.r2_operations++; + parts.push(await deadline.run(upload.uploadPart(parts.length + 1, buffer))); + buffer = new Uint8Array(PART_SIZE); + used = 0; + } + const size = Math.min(PART_SIZE - used, chunk.length - offset); + buffer.set(chunk.subarray(offset, offset + size), used); + used += size; + offset += size; + } + } + if (upload) { + stats.r2_operations += Number(used > 0) + 1; + if (used) + parts.push(await deadline.run(upload.uploadPart(parts.length + 1, buffer.subarray(0, used)))); + await deadline.run(upload.complete(parts)); + } else { + stats.r2_operations++; + const result = await deadline.run( + env.ARTIFACTS.put(generation.blob_object, buffer.subarray(0, used)), + ); + if (!result) unavailable(); + } + generation.blob_id = crypto.randomUUID(); +} + +export async function store( + request: Request, + env: Env, + ctx: Pick, + scope: Scope, + identity: WriteIdentity, + limits: Limits, + deadline: Deadline, + stats: Observations, +): Promise { + const boundary = boundaryFrom(request.headers.get('Content-Type')); + const length = contentLength(request, limits.store); + const generation = await deadline.run( + reserve(env.INDEX, scope, identity.exp, length ?? limits.store, stats), + ); + let input: Input | undefined; + try { + input = new Input(request.body, Math.min(length ?? limits.store, limits.store), deadline); + let metadata: StoreMetadata | undefined; + for await (const part of multipart(input, boundary, limits.headers)) { + if (part.name === 'metadata') { + metadata = decodeEnvelope(await collect(part.body, limits.metadata), true, limits); + generation.value_size = metadata.value.length; + stats.r2_operations++; + const result = await deadline.run( + env.ARTIFACTS.put(generation.value_object, metadata.value), + ); + if (!result) unavailable(); + } else { + await uploadBlob(env, generation, part.body, limits.blob, deadline, stats); + } + } + if (!metadata || (length !== null && input.bytes !== length)) badRequest(); + deadline.check(); + await deadline.run(publish(env.INDEX, generation, metadata.key, metadata.secondary_key, stats)); + return cborResponse({ blob_id: generation.blob_id }); + } catch (error) { + // Publication is never deferred. Only cleanup can continue after the response. + ctx.waitUntil( + abandon(env.INDEX, generation.generation_id).catch(() => { + console.warn( + JSON.stringify({ + operation: 'cleanup', + error: 'abandon_failed', + generation: generation.generation_id, + }), + ); + }), + ); + throw error; + } finally { + stats.request_bytes = input?.bytes ?? 0; + input?.close(); + } +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/streams.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/streams.ts new file mode 100644 index 000000000000..c67a9288ad6c --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/streams.ts @@ -0,0 +1,136 @@ +// @effect-diagnostics globalTimers:off - vendored upstream protocol, not an Effect service. +import { Buffer } from 'node:buffer'; +import { badRequest, HttpError, tooLarge } from './errors.ts'; + +export class Deadline { + private controller = new AbortController(); + private timer: ReturnType; + private cancelled: Promise; + private onAbort = () => this.controller.abort(); + constructor( + ms: number, + private signal?: AbortSignal, + ) { + this.timer = setTimeout(this.onAbort, ms); + signal?.addEventListener('abort', this.onAbort, { once: true }); + this.cancelled = new Promise((_, reject) => + this.controller.signal.addEventListener( + 'abort', + () => reject(new HttpError(503, 'deadline')), + { once: true }, + ), + ); + // Cancellation can precede the first awaited operation. + void this.cancelled.catch(() => {}); + if (signal?.aborted) this.onAbort(); + } + check(): void { + if (this.controller.signal.aborted) throw new HttpError(503, 'deadline'); + } + async run(operation: Promise): Promise { + this.check(); + return Promise.race([operation, this.cancelled]); + } + dispose(): void { + clearTimeout(this.timer); + this.signal?.removeEventListener('abort', this.onAbort); + } +} + +export async function collect( + source: AsyncIterable, + limit: number, +): Promise> { + let size = 0; + // Fixed-capacity accumulation also bounds bookkeeping for one-byte chunks. + const buffer = new Uint8Array(limit); + for await (const chunk of source) { + if (chunk.length > limit - size) tooLarge(); + buffer.set(chunk, size); + size += chunk.length; + } + return buffer.subarray(0, size); +} + +export class Input { + private reader: ReadableStreamDefaultReader; + private pending: Uint8Array = new Uint8Array(0); + private offset = 0; + bytes = 0; + ended = false; + buffer: Buffer = Buffer.alloc(0); + constructor( + body: ReadableStream | null, + private max: number, + private deadline: Deadline, + ) { + if (!body) badRequest(); + this.reader = body.getReader(); + } + async fill(): Promise { + if (this.ended) return false; + if (this.offset === this.pending.length) { + const result = await this.deadline.run(this.reader.read()); + if (result.done) { + this.ended = true; + return false; + } + this.bytes += result.value.length; + if (this.bytes > this.max) tooLarge(); + this.pending = result.value; + this.offset = 0; + } + const next = this.pending.subarray(this.offset, this.offset + 64 * 1024); + this.offset += next.length; + this.buffer = Buffer.concat([this.buffer, next]); + return true; + } + take(size: number): Buffer { + const result = this.buffer.subarray(0, size); + this.buffer = this.buffer.subarray(size); + return result; + } + async until(delimiter: Buffer, limit: number): Promise { + while (true) { + const index = this.buffer.indexOf(delimiter); + if (index >= 0) { + if (index > limit) tooLarge(); + const value = this.take(index); + this.take(delimiter.length); + return value; + } + if (this.buffer.length > limit + delimiter.length) tooLarge(); + if (!(await this.fill())) badRequest(); + } + } + async *rest(): AsyncGenerator { + do { + if (this.buffer.length) yield this.take(this.buffer.length); + } while (await this.fill()); + } + close(): void { + void this.reader.cancel().catch(() => {}); + } +} + +export async function readBody( + body: ReadableStream | null, + limit: number, + deadline: Deadline, +): Promise> { + const input = new Input(body, limit, deadline); + try { + return await collect(input.rest(), limit); + } finally { + input.close(); + } +} + +export function contentLength(request: Request, maximum: number): number | null { + const raw = request.headers.get('Content-Length'); + if (raw === null) return null; + if (!/^\d+$/.test(raw)) badRequest(); + const length = Number(raw); + if (!Number.isSafeInteger(length) || length > maximum) tooLarge(); + return length; +} diff --git a/knip.jsonc b/knip.jsonc index 30e289a534da..ed757fe9c20c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -115,6 +115,19 @@ // React Native codegen discovers component specifications by filename. "entry": ["src/*NativeComponent.ts!"], }, + "infra/task-cache": { + "ignore": ["vendor/**"], + // The bootstrap and Vite+ hook are loaded by shell/package configuration. + "entry": [ + "alchemy.run.ts!", + "scripts/install.ts", + "scripts/install-ci.ts", + "src/runHook.ts", + "src/flush.ts", + "src/nativeRunHook.js", + "src/preload.mjs", + ], + }, "infra/relay": { "entry": ["alchemy.run.ts!", "src/persistence/schema.ts!"], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d444d4b05e80..2cc59745d095 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1303,6 +1303,37 @@ importers: specifier: 1.3.0 version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + infra/task-cache: + dependencies: + alchemy: + specifier: 2.0.0-beta.80 + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(c74a1cfb3df8b67fc508b7f3e6f07baa) + effect: + specifier: 4.0.5 + version: 4.0.5 + jose: + specifier: 'catalog:' + version: 6.2.2 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260601.1 + version: 4.20260604.1 + '@effect/platform-node': + specifier: 4.0.5 + version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.5 + version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + typescript: + specifier: 'catalog:' + version: '@tsc-rs/typescript@0.8.0' + vite-plus: + specifier: 1.3.0 + version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + oxlint-plugin-t3code: dependencies: '@effect/platform-node': @@ -17047,6 +17078,64 @@ snapshots: - typescript - utf-8-validate + alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(c74a1cfb3df8b67fc508b7f3e6f07baa): + dependencies: + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5) + '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.5) + '@alchemy.run/node-utils': 2.0.0-beta.80 + '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.3.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@distilled.cloud/acme': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/aws': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/axiom': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/doppler': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/fly-io': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/gcp': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/hetzner': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/infisical': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/neon': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/planetscale': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/prisma': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/railway': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/stripe': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/zerossl': 1.0.0-rc.13(effect@4.0.5) + '@effect/sql-d1': 4.0.0(effect@4.0.5) + '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) + '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@neon/functions': 0.11.0(hono@4.13.7) + '@octokit/rest': 22.0.1 + '@octokit/webhooks': 14.2.0 + '@types/aws-lambda': 8.10.161 + capnweb: 0.12.0 + effect: 4.0.5 + fflate: 0.8.3 + libsodium-wrappers: 0.8.4 + pathe: 2.0.3 + picomatch: 4.0.7 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + yaml: 2.9.0 + optionalDependencies: + '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.5(effect@4.0.5) + '@effect/vitest': 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + drizzle-kit: 1.0.0-rc.5-ab785fc + drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.6.5) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) + pg: 8.23.0 + vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - bufferutil + - hono + - proxy-agent + - react-devtools-core + - typescript + - utf-8-validate + alien-signals@2.0.6: {} am-i-vibing@0.4.0: @@ -18013,6 +18102,22 @@ snapshots: pg: 8.23.0 zod: 4.4.3 + drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.6.5): + optionalDependencies: + '@cloudflare/workers-types': 4.20260604.1 + '@effect/sql-d1': 4.0.0(effect@4.0.5) + '@effect/sql-pg': 4.0.5(effect@4.0.5) + '@effect/sql-sqlite-bun': 4.0.0-rc.112(effect@4.0.5) + '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) + '@electric-sql/pglite': 0.3.15 + '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + bun-types: 1.3.14 + effect: 4.0.5 + expo-sqlite: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + pg: 8.23.0 + zod: 4.6.5 + optional: true + dset@3.1.4: {} dunder-proto@1.0.1: diff --git a/vite.config.ts b/vite.config.ts index 8495e43d6dce..164885182ede 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import * as NodeURL from "node:url"; +import { remoteCacheUrl } from "./infra/task-cache/src/remoteCache.ts"; const DOM_TEST_ENVIRONMENT_ALLOWLIST: Array = []; @@ -78,7 +79,17 @@ const RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS = { "Pick a glyph by meaning from PullRequestGlyph in apps/web/src/components/pullRequest/pullRequestIcons.tsx so every surface draws the same pull request the same way.", }; +// Vite+ supports remote configuration before its published types include it. +const cacheUrl = remoteCacheUrl(import.meta.dirname); +const taskCacheConfig = { + scripts: false, + ...(cacheUrl ? { remote: { url: cacheUrl } } : {}), +}; + export default defineConfig({ + run: { + cache: taskCacheConfig, + }, resolve: { alias: { "~": NodeURL.fileURLToPath(new URL("./apps/web/src", import.meta.url)), @@ -86,6 +97,8 @@ export default defineConfig({ }, test: { environment: "node", + // Fixture subprocesses must not inherit the outer task runner’s cache policy. + env: { VP_REMOTE_CACHE: "off", VP_REMOTE_CACHE_URL: "" }, exclude: TEST_EXCLUDE, hookTimeout: 60_000, testTimeout: 60_000, @@ -141,6 +154,7 @@ export default defineConfig({ fmt: { ignorePatterns: [ ".repos/**", + "infra/task-cache/vendor/**", // Macroscope requires one glob per line; Markdown formatting corrupts it. ".macroscope/ignore.md", ".alchemy", @@ -175,6 +189,7 @@ export default defineConfig({ ignorePatterns: [ ".repos", ".repos/**", + "infra/task-cache/vendor/**", "dist", "dist-electron", "node_modules", From ebd29ca951e1e9303ef8fd07e72ed52264b61583 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:07:27 -0700 Subject: [PATCH 15/34] fix(mobile): screen lists register with their header, enforced by lint (#24710) Co-authored-by: Claude Opus 5.5 --- .../archive/ArchivedThreadsScreen.tsx | 2 + .../src/features/files/FileTreeBrowser.tsx | 2 + .../pull-requests/PullRequestFiles.tsx | 2 + .../SettingsOpenSourceLicensesRouteScreen.tsx | 2 + .../threads/NewTaskContextPickerScreens.tsx | 2 + .../src/features/threads/ThreadFeed.tsx | 1 + .../threads/ThreadNavigationSidebar.tsx | 3 ++ .../threads/ThreadPullRequestsSheet.tsx | 1 + .../features/threads/ThreadSettingsSheet.tsx | 1 + .../src/features/threads/thread-work-log.tsx | 1 + oxlint-plugin-t3code/index.ts | 2 + .../require-registered-list-scroll.test.ts | 32 +++++++++++++++ .../rules/require-registered-list-scroll.ts | 40 +++++++++++++++++++ vite.config.ts | 5 ++- 14 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 oxlint-plugin-t3code/rules/require-registered-list-scroll.test.ts create mode 100644 oxlint-plugin-t3code/rules/require-registered-list-scroll.ts diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 64443efaa838..90f722e53669 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -1,3 +1,4 @@ +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { Button } from "../../components/ui/Button"; import type { EnvironmentProject, @@ -428,6 +429,7 @@ export function ArchivedThreadsScreen(props: { item.key} diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx index eba94cdf3bd6..e9774d37d4d1 100644 --- a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -1,3 +1,4 @@ +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { SearchField } from "../../components/ui/SearchField"; import { Toolbar } from "../../components/ui/Toolbar"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; @@ -127,6 +128,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { ) : null} ) : ( + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- ThreadFeedScrollView is passed through the iOS-only spread below; the prop rejects undefined. */} + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- Only the non-native workspace layout (Android and the fallback) renders this sidebar, which has no native header to register with. */} + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- Sheet content; sheets sit outside workspace columns and keep their own header handling. */} + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- Nested in a fixed-height card inside the thread feed, which owns the header's scroll view. */} { + rule.valid( + "allows a list that renders through a registered scroll view", + `const el = ;`, + ); + + rule.valid( + "allows a keyboard-aware list with its scroll view component", + `const el = ;`, + ); + + rule.invalid( + "reports a list given the other list's scroll prop", + `const el = ;`, + ); + + rule.invalid( + "reports a list with the default scroll view", + `const el = ;`, + (output) => { + assert.match(output, /header-registered scroll view/); + }, + ); +}); diff --git a/oxlint-plugin-t3code/rules/require-registered-list-scroll.ts b/oxlint-plugin-t3code/rules/require-registered-list-scroll.ts new file mode 100644 index 000000000000..7645ea77c055 --- /dev/null +++ b/oxlint-plugin-t3code/rules/require-registered-list-scroll.ts @@ -0,0 +1,40 @@ +import { defineRule } from "@oxlint/plugins"; + +// LegendList takes a render function; KeyboardAwareLegendList takes the component itself. +const SCROLL_COMPONENT_PROP: Readonly> = { + LegendList: "renderScrollComponent", + AnimatedLegendList: "renderScrollComponent", + KeyboardAwareLegendList: "ScrollViewComponent", +}; + +// A screen's list must scroll in a scroll view registered with its header, or the header loses +// its scroll-edge blur (and, on the iPhone Duo, the blur's reach under the vertical bar). +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require mobile lists to render through a header-registered scroll view (renderScrollComponent).", + }, + }, + create(context) { + return { + JSXOpeningElement(node) { + if (node.name.type !== "JSXIdentifier") return; + const prop = SCROLL_COMPONENT_PROP[node.name.name]; + if (!prop) return; + const registered = node.attributes.some( + (attribute) => + attribute.type === "JSXAttribute" && + attribute.name.type === "JSXIdentifier" && + attribute.name.name === prop, + ); + if (registered) return; + context.report({ + node, + message: `Pass ${prop} so the list scrolls in a header-registered scroll view: NativeScrollView on iOS, or a component wrapping NativeScrollViewMarker. A list nested in another scroll surface or sheet can suppress this with a reason.`, + }); + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 164885182ede..63dd5266bf86 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -439,7 +439,10 @@ export default defineConfig({ }, { files: ["apps/mobile/src/**"], - rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, + rules: { + "t3code/no-mobile-uniwind-theme-escape-hatches": "error", + "t3code/require-registered-list-scroll": "error", + }, }, { files: ["apps/mobile/src/**"], From b3d382e1009d5430de8c528aef99a39854a7cbc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:07:32 -0700 Subject: [PATCH 16/34] feat(mobile): search Settings, and keep its sidebar header clean (#24766) Co-authored-by: Claude Opus 5.5 --- .../createWorkspaceStackNavigator.ios.tsx | 15 +- .../features/settings/SettingsRouteScreen.tsx | 294 +++++++++--------- .../settings/components/SettingsScreen.tsx | 3 +- .../features/settings/settings-search.test.ts | 68 ++++ .../src/features/settings/settings-search.ts | 120 +++++++ .../workspace/WorkspaceRootScreen.tsx | 39 +-- .../workspace/WorkspaceTabsContext.tsx | 11 +- .../workspace/WorkspaceToolbar.ios.tsx | 95 +++--- .../workspace/workspace-tabs-state.test.ts | 14 + .../workspace/workspace-tabs-state.ts | 17 +- 10 files changed, 449 insertions(+), 227 deletions(-) create mode 100644 apps/mobile/src/features/settings/settings-search.test.ts create mode 100644 apps/mobile/src/features/settings/settings-search.ts diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index d9c233a90f35..5a42b61d2594 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -39,7 +39,6 @@ import { workspaceUsesColumns } from "../workspace/workspace-tabs-state"; import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { setNativelyPoppingWorkspaceRoute } from "../workspace/workspace-detail-selection"; -import { useWorkspaceSidebarHeaderItem } from "./useWorkspaceSidebarHeaderItem"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; @@ -94,26 +93,15 @@ function ColumnScreen(props: { readonly onDismiss: (routeKey: string, native: boolean) => void; readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void; readonly onCompose?: () => void; - /** Settings pages don't draw their own sidebar toggle, unlike threads and pull requests. */ - readonly sidebarToggle?: boolean; readonly navigation: ViewProps["navigation"]; }) { const { descriptor } = props; // Options a screen set inside this workspace tab slot, over its route's. - const slotOptions = slotScreenOptions( + const options = slotScreenOptions( use(WorkspaceSlotOptionsContext), descriptor.route.key, descriptor.options, ); - const sidebarItem = useWorkspaceSidebarHeaderItem(); - // Swapping header components would unmount the native header config, which hides the bar. - const options = - props.sidebarToggle && !slotOptions.unstable_headerLeftItems - ? ({ - ...slotOptions, - unstable_headerLeftItems: () => [sidebarItem], - } as AppNativeStackNavigationOptions as typeof slotOptions) - : slotOptions; const [presented, setPresented] = useState(false); const primaryColumn = useMemo( () => @@ -420,7 +408,6 @@ function WorkspaceColumns( key={screenKey} screenKey={screenKey} descriptor={descriptor} - sidebarToggle={inSettingsSlot} attached={props.detail.some((current) => current.key === route.key)} onDismiss={handleNativeDismiss} onAppearance={followCollapsedSplit} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 826d352fb4a6..2f7406d5b782 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -11,13 +11,15 @@ import { import { currentWorkspaceSelections } from "../workspace/workspace-selection-store"; import { rememberedSettingsPage } from "../workspace/workspace-tab-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; +import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { ScreenScrollView as ScrollView, ScreenScrollViewBottomInset, } from "../../components/ui/ScreenScrollView"; +import { EmptyState } from "../../components/ui/EmptyState"; import { useAuth, useUser } from "@clerk/expo"; import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { use, useMemo } from "react"; +import { use, useMemo, type ComponentProps } from "react"; import { Platform, View } from "react-native"; import { useWorkspaceSurface } from "../workspace/useWorkspaceSurface"; import { useDeviceType } from "../../native/native-layout-metrics"; @@ -31,17 +33,20 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection, SettingsSidebarContext } from "./components/SettingsSection"; import { SettingsScreen } from "./components/SettingsScreen"; +import type { SettingsPageTarget } from "./components/settings-page-targets"; import { AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, } from "./components/SettingsEnvironmentFilterHeader"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; +import { filterSettingsSections } from "./settings-search"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; export function SettingsRouteScreen() { const surface = useWorkspaceSurface(); const deviceType = useDeviceType(); const compactTabs = useCompactWorkspaceTabs(); + const workspace = useWorkspaceTabs(); const { layout } = useAdaptiveWorkspaceLayout(); const inSidebar = use(NativePrimaryColumnContext) !== null && layout.usesSplitView; // The page open beside the list: the route right above the Settings tab's route. While @@ -66,7 +71,20 @@ export function SettingsRouteScreen() { {Platform.OS === "android" ? ( - }> + } + search={ + workspace + ? { + value: workspace.state.tabs.settings.query, + onChangeText: (query) => + workspace.dispatch({ type: "query", tab: "settings", query }), + placeholder: "Search settings", + } + : undefined + } + > {content} ) : ( @@ -78,12 +96,14 @@ export function SettingsRouteScreen() { ); } +type SettingsListRow = ComponentProps & { readonly id: string }; + +interface SettingsListSection { + readonly title?: string; + readonly rows: readonly SettingsListRow[]; +} + function ConfiguredSettingsRouteScreen() { - // UIKit's scroll-edge material takes the scroll view's own color; like the chat list, match - // the sidebar's surface so the bar doesn't darken over content at rest. - // Grouped cards only lift off the screen surface; the drawer is as dark as they are. - const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; - const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); @@ -95,77 +115,96 @@ function ConfiguredSettingsRouteScreen() { : (user?.primaryEmailAddress?.emailAddress ?? "Signed in"); return ( - - - - - navigation.navigate("SettingsAuth")} - /> - - - - - - - + navigation.navigate("SettingsAuth"), + }, + environmentsRow(Object.keys(savedConnectionsById).length), + { + id: "SettingsNotifications", + icon: "bell.badge", + label: "Notifications", + target: "SettingsNotifications", + }, + ]} + /> ); } function LocalSettingsRouteScreen() { + const { savedConnectionsById } = useSavedRemoteConnections(); + return ; +} + +function environmentsRow(count: number): SettingsListRow { + return { + id: "SettingsEnvironments", + icon: "desktopcomputer", + label: "Environments", + value: `${count}`, + valuePosition: "trailing", + target: "SettingsEnvironments", + }; +} + +function SettingsList(props: { readonly connections: readonly SettingsListRow[] }) { // UIKit's scroll-edge material takes the scroll view's own color; like the chat list, match // the sidebar's surface so the bar doesn't darken over content at rest. // Grouped cards only lift off the screen surface; the drawer is as dark as they are. const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; const insets = useSafeAreaInsets(); - const { savedConnectionsById } = useSavedRemoteConnections(); - const environmentCount = Object.keys(savedConnectionsById).length; + const workspace = useWorkspaceTabs(); + const query = workspace?.state.tabs.settings.query ?? ""; + const overflowRows = useOverflowRows(); + const indexSections = useSettingsIndexSections(); + const sections = filterSettingsSections( + [{ rows: overflowRows }, { title: "Connections", rows: props.connections }, ...indexSections], + query, + ); return ( - - - ( + + {section.rows.map(({ id, ...row }) => ( + + ))} + + )) + ) : ( + - - - + )} ); } -function SettingsIndexSections() { +function useSettingsIndexSections(): SettingsListSection[] { const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); const noServerTargets = selectedTargets.length === 0; const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); @@ -182,101 +221,74 @@ function SettingsIndexSections() { members: scopedProjectMembers, }) : (selectedProject?.label ?? "Unavailable project"); - return ( - <> - - - {navigationCustomizationAvailable() ? ( - - ) : null} - {Platform.OS === "ios" ? ( - <> - - - - ) : null} - - - - - - - - {selectedProjectKey !== null ? ( - - ) : null} - - - - - - - - - - - - - - - - - - - ); + const page = ( + target: SettingsPageTarget, + icon: SettingsListRow["icon"], + label: string, + extra?: Pick, + ): SettingsListRow => ({ id: target, icon, label, target, ...extra }); + const serverPage = (target: SettingsPageTarget, icon: SettingsListRow["icon"], label: string) => + page(target, icon, label, { disabled: noServerTargets }); + return [ + { + title: "Interface", + rows: [ + page("SettingsAppearance", "paintbrush", "Appearance"), + ...(navigationCustomizationAvailable() + ? [page("SettingsNavigation", "line.3.horizontal", "Navigation tabs")] + : []), + ...(Platform.OS === "ios" + ? [ + page("SettingsKeyboard", "keyboard", "Keyboard"), + page("SettingsMicrophone", "mic", "Microphone"), + ] + : []), + ], + }, + { + title: "Automations", + rows: [page("SettingsScheduledTasks", "clock", "Scheduled tasks")], + }, + { + title: "Projects & threads", + rows: [ + ...(selectedProjectKey !== null + ? [page("SettingsProjectOverview", "folder", "Overview", { value: projectLabel })] + : []), + page("SettingsOrganization", "folder", "Organization"), + page("SettingsThreads", "text.bubble", "Thread behavior"), + page("SettingsFollowUp", "arrow.turn.left.up", "Follow-ups"), + page("SettingsArchive", "archivebox", "Archived Threads"), + ], + }, + { + title: "Server settings", + rows: [ + serverPage("SettingsProviderAccounts", "person.crop.circle", "Provider accounts"), + serverPage("SettingsEnvironmentNewThreads", "text.bubble", "New threads"), + serverPage("SettingsEnvironmentSourceControl", "arrow.triangle.branch", "Source control"), + serverPage("SettingsEnvironmentAgentBehavior", "text.alignleft", "Agent behavior"), + serverPage("SettingsEnvironmentMaintenance", "arrow.clockwise", "Maintenance"), + ], + }, + { + title: "App", + rows: [page("SettingsAbout", "info.circle", "About T3 Code")], + }, + ]; } -function OverflowSection() { +function useOverflowRows(): SettingsListRow[] { const order = useNavigationTabOrder(); const hidden = useHiddenNavigationTabs(); const { layout } = useAdaptiveWorkspaceLayout(); const tabs = Platform.OS === "android" && layout.usesSplitView ? [] : overflowNavigationTabs(order, hidden); const selectTab = useSelectWorkspaceTab(); - return tabs.length ? ( - - {tabs.map((tab) => ( - selectTab(tab)} - /> - ))} - - ) : null; + return tabs.map((tab) => ({ + id: tab, + icon: NAVIGATION_TAB_ICONS[tab], + label: WORKSPACE_TAB_LABELS[tab], + onPress: () => selectTab(tab), + })); } diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 85a8258187a1..74a749ed24d4 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -17,7 +17,7 @@ import { export { SettingsScreenContent }; export function SettingsScreen( - props: Pick & { + props: Pick & { readonly children: ReactNode; readonly formSheet?: boolean; readonly workspaceRoot?: boolean; @@ -44,6 +44,7 @@ export function SettingsScreen( navigation.goBack()} diff --git a/apps/mobile/src/features/settings/settings-search.test.ts b/apps/mobile/src/features/settings/settings-search.test.ts new file mode 100644 index 000000000000..6c11a3dae7f7 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-search.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { filterSettingsSections } from "./settings-search"; + +const SECTIONS = [ + { rows: [{ id: "usage", label: "Usage" }] }, + { + title: "Connections", + rows: [ + { id: "account", label: "T3 Account" }, + { id: "SettingsEnvironments", label: "Environments" }, + ], + }, + { + title: "Interface", + rows: [ + { id: "SettingsAppearance", label: "Appearance" }, + { id: "SettingsKeyboard", label: "Keyboard" }, + ], + }, + { + title: "Server settings", + rows: [ + { id: "SettingsProviderAccounts", label: "Provider accounts" }, + { id: "SettingsEnvironmentSourceControl", label: "Source control" }, + ], + }, +]; + +function visible(query: string) { + return filterSettingsSections(SECTIONS, query).map((section) => [ + section.title ?? null, + section.rows.map((row) => row.label), + ]); +} + +describe("filterSettingsSections", () => { + it.each([ + { + query: "", + expected: SECTIONS.map((section) => [ + section.title ?? null, + section.rows.map((row) => row.label), + ]), + }, + { query: " ", expected: visible("") }, + { query: "appear", expected: [["Interface", ["Appearance"]]] }, + { query: "KEYBOARD", expected: [["Interface", ["Keyboard"]]] }, + { query: "dark mode", expected: [["Interface", ["Appearance"]]] }, + { query: "branch naming", expected: [["Server settings", ["Source control"]]] }, + { + query: "server settings", + expected: [["Server settings", ["Provider accounts", "Source control"]]], + }, + { + query: "accounts", + expected: [["Server settings", ["Provider accounts"]]], + }, + { query: "usage", expected: [[null, ["Usage"]]] }, + { query: "zzzz", expected: [] }, + ])("$query", ({ query, expected }) => { + expect(visible(query)).toEqual(expected); + }); + + it("drops sections that start empty", () => { + expect(filterSettingsSections([{ rows: [] }, ...SECTIONS], "").length).toBe(SECTIONS.length); + }); +}); diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts new file mode 100644 index 000000000000..f82f90dfe833 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -0,0 +1,120 @@ +import { foldSearchQuery, rankSearchItems, scoreSearchTerms } from "@t3tools/shared/paletteSearch"; + +import type { SettingsPageTarget } from "./components/settings-page-targets"; + +/** What each page holds besides its row's label, so search finds a setting by its own name. */ +const SETTINGS_SEARCH_TERMS = { + account: ["sign in", "sign out", "login", "clerk", "connect"], + SettingsEnvironments: [ + "servers", + "connections", + "pair", + "add environment", + "remote", + "tailscale", + ], + SettingsNotifications: ["push", "alerts", "live activity", "agent activity"], + SettingsAppearance: [ + "theme", + "dark mode", + "light mode", + "colors", + "font size", + "text size", + "code", + "diffs", + "terminal", + "word break", + "math", + ], + SettingsNavigation: ["tabs", "tab order", "reorder", "hide tabs"], + SettingsKeyboard: ["return key", "send message", "new line", "hardware keyboard"], + SettingsMicrophone: ["voice", "dictation", "audio input", "bluetooth"], + SettingsScheduledTasks: ["automations", "schedule", "cron", "recurring", "webhook"], + SettingsProjectOverview: ["project", "checkouts", "worktrees", "delete project"], + SettingsOrganization: ["project grouping", "group projects", "sidebar"], + SettingsThreads: [ + "auto-settle", + "settle", + "snooze", + "usage limits", + "auto-resume", + "plan mode", + "working section", + ], + SettingsFollowUp: ["queue", "steer", "while the agent is running"], + SettingsArchive: ["archive", "archived", "restore", "unarchive"], + SettingsProviderAccounts: ["codex", "claude", "cursor", "opencode", "sign in", "login", "auth"], + SettingsEnvironmentNewThreads: [ + "default workspace", + "worktree", + "submodules", + "default permissions", + "access", + ], + SettingsEnvironmentSourceControl: [ + "git", + "branch naming", + "pull requests", + "co-authors", + "agent credits", + "default branch", + "automatically pull", + "worktrees", + "start from remote", + ], + SettingsEnvironmentAgentBehavior: ["response streaming", "preview browser", "browser access"], + SettingsEnvironmentMaintenance: ["updates", "provider updates", "restart", "manage environments"], + SettingsAbout: [ + "version", + "diagnostics", + "client storage", + "cache", + "legal", + "privacy", + "terms", + "open source licenses", + ], +} satisfies Partial>; +const termsById: ReadonlyMap = new Map( + Object.entries(SETTINGS_SEARCH_TERMS), +); + +export interface SettingsSearchRow { + readonly id: string; + readonly label: string; +} + +export interface SettingsSearchSection { + readonly title?: string; + readonly rows: readonly Row[]; +} + +/** + * Keeps the rows whose label, section, or page contents match `query`, in list order, and drops + * sections left empty. Matching is the command palette's, so both searches agree. + */ +export function filterSettingsSections< + Row extends SettingsSearchRow, + Section extends SettingsSearchSection, +>(sections: readonly Section[], query: string): Section[] { + const normalizedQuery = foldSearchQuery(query); + const rows = sections.flatMap((section) => + section.rows.map((row) => ({ row, section: section.title })), + ); + const matched = normalizedQuery + ? new Set( + rankSearchItems(rows, ({ row, section }, options) => + scoreSearchTerms( + normalizedQuery, + [row.label, ...(section ? [section] : []), ...(termsById.get(row.id) ?? [])], + options, + ), + ).map(({ item }) => item.row), + ) + : null; + return sections.flatMap((section) => { + const kept = matched ? section.rows.filter((row) => matched.has(row)) : section.rows; + return kept.length ? [{ ...section, rows: kept }] : []; + }); +} diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index 69efd96411fa..8778ba2b3644 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -54,16 +54,17 @@ function WorkspaceRootTitle(props: { readonly maxWidth: number; readonly onOpenEnvironments: () => void; }) { - const { state } = useWorkspaceTabs()!; + const { state, settingsOpen } = useWorkspaceTabs()!; const enabled = useEnabledNavigationTabs(); + const tabTitle = settingsOpen + ? "Settings" + : enabled.length + ? TAB_TITLES[state.selectedTab] + : "Navigation"; return ( - } + brand={} maxWidth={props.maxWidth} onPress={props.onOpenEnvironments} /> @@ -106,9 +107,9 @@ function WorkspaceRootContent() { ), [width, navigation], ); - const tab = state.tabs[state.selectedTab]; // Settings is a workspace tab too; its list takes the place of the selected tab's. const settingsOpen = workspace.settingsOpen; + const tab = state.tabs[settingsOpen ? "settings" : state.selectedTab]; const bottomClearance = layout.primarySidebarOverlaysContent && state.selectedTab === "usage" ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) @@ -164,8 +165,12 @@ function WorkspaceRootContent() { ? { headerSearchBarOptions: undefined, unstable_headerRightItems: () => [] } : {}), unstable_headerLeftItems: () => [], + // Beside a detail column the tab bar names the tab and the detail header the page, + // so the sidebar keeps the brand. headerTitle: - settingsOpen || state.selectedTab === "usage" ? undefined : renderHeaderTitle, + sidebarNavigation || (!settingsOpen && state.selectedTab !== "usage") + ? renderHeaderTitle + : undefined, }} /> ) : ( @@ -199,10 +204,7 @@ function WorkspaceRootContent() { } > {settingsOpen ? ( - <> - - - + ) : enabled.length === 0 ? ( ) : state.selectedTab === "threads" ? ( @@ -216,14 +218,15 @@ function WorkspaceRootContent() { {deviceType !== "pad" && deviceType !== "phone" && sidebarPicker ? ( ) : null} - {enabled.length === 0 && !settingsOpen ? null : !settingsOpen && - (state.selectedTab === "threads" || state.selectedTab === "pullRequests") ? ( + {/* Android's Settings list carries its own search in its app bar. */} + {enabled.length === 0 && !settingsOpen ? null : ( + settingsOpen + ? Platform.OS === "ios" + : state.selectedTab === "threads" || state.selectedTab === "pullRequests" + ) ? ( ) : ( - + )} diff --git a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx index f8e23235d3d8..2c3a10e4b0c4 100644 --- a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx @@ -14,6 +14,7 @@ import { import { initialWorkspaceTabsState, workspaceTabsReducer, + type WorkspaceSearchTab, type WorkspaceTab, type WorkspaceTabsAction, type WorkspaceTabsState, @@ -25,8 +26,8 @@ export const WorkspaceTabsContext = createContext<{ readonly isTabActive: (tab: WorkspaceTab) => boolean; readonly bottomClearance: number; readonly settingsOpen: boolean; - readonly getScrollOffset: (tab: WorkspaceTab) => number; - readonly recordScrollOffset: (tab: WorkspaceTab, offset: number) => void; + readonly getScrollOffset: (tab: WorkspaceSearchTab) => number; + readonly recordScrollOffset: (tab: WorkspaceSearchTab, offset: number) => void; } | null>(null); export function useWorkspaceTabs() { @@ -47,9 +48,9 @@ export function WorkspaceTabsProvider(props: { activeTab.current = state.selectedTab; }, [state.selectedTab]); const isTabActive = useCallback((tab: WorkspaceTab) => activeTab.current === tab, []); - const scrollOffsets = useRef({ threads: 0, pullRequests: 0, usage: 0 }); - const getScrollOffset = useCallback((tab: WorkspaceTab) => scrollOffsets.current[tab], []); - const recordScrollOffset = useCallback((tab: WorkspaceTab, offset: number) => { + const scrollOffsets = useRef({ threads: 0, pullRequests: 0, usage: 0, settings: 0 }); + const getScrollOffset = useCallback((tab: WorkspaceSearchTab) => scrollOffsets.current[tab], []); + const recordScrollOffset = useCallback((tab: WorkspaceSearchTab, offset: number) => { scrollOffsets.current[tab] = offset; }, []); const context = useMemo( diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index 17fff053258e..821c4c70c34b 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -19,6 +19,12 @@ import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { useCompactWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; import { WorkspaceTabToolbarButtons } from "./WorkspaceSectionToolbar"; +const SEARCH_LABELS = { + threads: "Search threads", + pullRequests: "Search pull requests", + settings: "Search settings", +} as const; + export function WorkspaceToolbar(props: { readonly onNewTask: () => void; readonly sidebar?: boolean; @@ -34,15 +40,23 @@ export function WorkspaceToolbar(props: { const nativeToolbarCompose = Platform.OS === "ios" && deviceType === "duo-folded" && + !workspace.settingsOpen && workspace.state.selectedTab === "threads" && activeNewTask === null; - const { isTabActive } = workspace; const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; const focused = useIsFocused(); const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const searchRef = useRef(null); - const tab = workspace.state.tabs[workspace.state.selectedTab]; - const selectedTab = workspace.state.selectedTab; + // The Settings list takes the selected tab's place, so search follows it. + const selectedTab = workspace.settingsOpen ? "settings" : workspace.state.selectedTab; + const tab = workspace.state.tabs[selectedTab]; + const searchLabel = SEARCH_LABELS[selectedTab === "usage" ? "threads" : selectedTab]; + const { isTabActive: isListTabActive } = workspace; + const isTabActive = useCallback( + // A retained tab slot stays mounted while another is shown. + (tab: typeof selectedTab) => (tab === "settings" ? tabActive : isListTabActive(tab)), + [tabActive, isListTabActive], + ); const foldedSearch = deviceType === "duo-folded"; const primaryColumn = use(NativePrimaryColumnContext); // UIKit hosts integrated search outside the sidebar's sliding content. @@ -60,11 +74,11 @@ export function WorkspaceToolbar(props: { if (props.sidebar && !panes.primarySidebarVisible) togglePrimarySidebar(); }, [workspace, selectedTab, props.sidebar, panes.primarySidebarVisible, togglePrimarySidebar]); const focusSearch = useCallback(() => { - if (!workspace.isTabActive(selectedTab)) return false; + if (!isTabActive(selectedTab)) return false; if (duoSearch || !NATIVE_LIQUID_GLASS_SUPPORTED) enterSearch(); searchRef.current?.focus(); return true; - }, [workspace, selectedTab, enterSearch, duoSearch]); + }, [isTabActive, selectedTab, enterSearch, duoSearch]); const cancelSearch = useCallback(() => { workspace.recordScrollOffset(selectedTab, tab.searchEntryOffset); workspace.dispatch({ type: "cancel", tab: selectedTab }); @@ -124,13 +138,7 @@ export function WorkspaceToolbar(props: { const searchButton = foldedSearch ? ( searchRef.current?.cancelSearch() : focusSearch} separateBackground @@ -142,8 +150,13 @@ export function WorkspaceToolbar(props: { optionsVersion={[selectedTab, tab.searchMode]} listeners={{ transitionEnd: restoreSearch }} options={{ + // Without Liquid Glass, Settings keeps its search field out instead of a search button, + // which would take the header's trailing items from the scope filter. headerSearchBarOptions: - (NATIVE_LIQUID_GLASS_SUPPORTED || tab.searchMode !== "resting") && searchVisible + (NATIVE_LIQUID_GLASS_SUPPORTED || + tab.searchMode !== "resting" || + workspace.settingsOpen) && + searchVisible ? { ref: searchRef, autoFocus: tab.searchMode === "active", @@ -160,8 +173,7 @@ export function WorkspaceToolbar(props: { NATIVE_LIQUID_GLASS_SUPPORTED && deviceType !== "phone" && sidebarTabs !== "picker", - placeholder: - selectedTab === "pullRequests" ? "Search pull requests" : "Search threads", + placeholder: searchLabel, cancelButtonHidden: foldedSearch, onFocus: searchFocused, onBlur: () => workspace.dispatch({ type: "expand", tab: selectedTab }), @@ -177,32 +189,33 @@ export function WorkspaceToolbar(props: { : undefined, }} /> - {/* UIKit lays trailing items out from the edge inward. */} - - {searchButton} - {nativeToolbarCompose && foldedSearch ? : null} - {composeButton} - {selectedTab !== "threads" || - isDuoDevice(deviceType) || - deviceType === "pad" || - sidebarTabs === "picker" || - compactTabs ? null : ( - - )} - {NATIVE_LIQUID_GLASS_SUPPORTED ? null : ( - - )} - + {/* UIKit lays trailing items out from the edge inward. A trailing toolbar replaces the + header's own items, so an empty one would hide the Settings scope filter. */} + {workspace.settingsOpen && !foldedSearch ? null : ( + + {searchButton} + {nativeToolbarCompose && foldedSearch ? : null} + {composeButton} + {selectedTab !== "threads" || + isDuoDevice(deviceType) || + deviceType === "pad" || + sidebarTabs === "picker" || + compactTabs ? null : ( + + )} + {NATIVE_LIQUID_GLASS_SUPPORTED ? null : ( + + )} + + )} {sidebarTabs === "toolbar" ? : null} {bottomSearch && searchVisible ? : null} diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts index f476d009857b..d0f72faa0772 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts @@ -110,6 +110,20 @@ describe("workspace root navigation", () => { expect(workspaceTabsReducer(state, { type: "cancel" }).tabs.threads.scrollOffset).toBe(500); }); + it("keeps the Settings search apart from the tab Settings opened over", () => { + let state = workspaceTabsReducer(initialWorkspaceTabsState(), { + type: "query", + query: "diff", + tab: "threads", + }); + state = workspaceTabsReducer(state, { type: "search", tab: "settings" }); + state = workspaceTabsReducer(state, { type: "query", query: "theme", tab: "settings" }); + expect(state.tabs.settings).toMatchObject({ query: "theme", searchMode: "active" }); + state = workspaceTabsReducer(state, { type: "cancel", tab: "settings" }); + expect(state.tabs.settings).toMatchObject({ query: "", searchMode: "resting" }); + expect(state.tabs.threads.query).toBe("diff"); + }); + it("a late native blur cannot reopen a cancelled search", () => { let state = workspaceTabsReducer(initialWorkspaceTabsState(), { type: "search" }); state = workspaceTabsReducer(state, { type: "cancel" }); diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.ts index 6355796c84ef..f216fb9f13ca 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.ts @@ -12,6 +12,8 @@ export function resolveWorkspaceTabOrder(saved: readonly unknown[] | undefined) } export type WorkspaceTab = NavigationTab; +/** Lists with their own search: the searchable tabs and the Settings list. */ +export type WorkspaceSearchTab = WorkspaceTab | "settings"; export type WorkspaceSearchMode = "resting" | "active" | "expanded"; /** Settings, Chats and pull requests pair a list with a detail column; Usage is one page. */ @@ -29,7 +31,7 @@ export interface WorkspaceTabState { export interface WorkspaceTabsState { readonly selectedTab: WorkspaceTab; readonly selectionRevision: number; - readonly tabs: Readonly>; + readonly tabs: Readonly>; } function emptyTab(): WorkspaceTabState { @@ -44,17 +46,18 @@ export function initialWorkspaceTabsState(): WorkspaceTabsState { threads: emptyTab(), pullRequests: emptyTab(), usage: emptyTab(), + settings: emptyTab(), }, }; } export type WorkspaceTabsAction = | { readonly type: "select"; readonly tab: WorkspaceTab } - | { readonly type: "search"; readonly tab?: WorkspaceTab } - | { readonly type: "query"; readonly query: string; readonly tab?: WorkspaceTab } - | { readonly type: "expand"; readonly tab?: WorkspaceTab } - | { readonly type: "cancel"; readonly tab?: WorkspaceTab } - | { readonly type: "scroll"; readonly tab: WorkspaceTab; readonly offset: number }; + | { readonly type: "search"; readonly tab?: WorkspaceSearchTab } + | { readonly type: "query"; readonly query: string; readonly tab?: WorkspaceSearchTab } + | { readonly type: "expand"; readonly tab?: WorkspaceSearchTab } + | { readonly type: "cancel"; readonly tab?: WorkspaceSearchTab } + | { readonly type: "scroll"; readonly tab: WorkspaceSearchTab; readonly offset: number }; export function workspaceTabsReducer( state: WorkspaceTabsState, @@ -69,7 +72,7 @@ export function workspaceTabsReducer( selectionRevision: state.selectionRevision + 1, }; const tab = action.tab ?? state.selectedTab; - if (tab !== "threads" && tab !== "pullRequests") return state; + if (tab !== "threads" && tab !== "pullRequests" && tab !== "settings") return state; const current = state.tabs[tab]; let next: WorkspaceTabState; switch (action.type) { From 8d3261ebc222f26a402afa2332b599925ddc43b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:41:07 -0700 Subject: [PATCH 17/34] fix(mobile): threads open with less waiting, and the Threads tab remembers drafts (#24750) Co-authored-by: Claude Opus 5.5 --- .../features/home/thread-swipe-actions.tsx | 117 ++++++++++++------ .../layout/AdaptiveWorkspaceLayout.tsx | 2 + .../features/threads/ThreadDetailScreen.tsx | 23 +++- .../workspace/useSelectWorkspaceDetail.ts | 21 +++- .../workspace/workspace-selection-store.ts | 2 +- .../workspace-tab-navigation.test.ts | 24 ++++ .../workspace/workspace-tab-navigation.ts | 22 +++- .../src/persistence/mobile-preferences.ts | 2 + apps/mobile/src/state/threads.ts | 12 ++ 9 files changed, 179 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 493a5848fe59..e75b29a8805a 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -121,6 +121,9 @@ function resolveSecondaryAction(input: { /** Keep the scroll gate in context so scroll start cannot re-render every row and its subscriptions. */ const SwipeableScrollGateContext = createContext(true); +// Swipeable rebuilds its native pan gesture whenever its config changes identity, so the props +// that feed it stay stable across row re-renders such as selection. +const FAIL_OFFSET_Y: [number, number] = [-10, 10]; export function SwipeableScrollGateProvider(props: { readonly enabled: boolean; @@ -402,11 +405,77 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { }, [content], ); - const handleWillOpen = () => { - if (!props.dormant && !dismissalRef.current && swipeableRef.current) { - props.onSwipeableWillOpen?.(swipeableRef.current); + const { dormant, onSwipeableWillOpen, backgroundColor, containerStyle } = props; + const handleWillOpen = useCallback(() => { + if (!dormant && !dismissalRef.current && swipeableRef.current) { + onSwipeableWillOpen?.(swipeableRef.current); } + }, [dormant, onSwipeableWillOpen]); + const handleSwipeableClose = useCallback(() => { + if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); + }, [onSwipeableClose]); + const compactActions = props.compactActions === true; + const { secondaryAction, threadTitle } = props; + // Swipeable calls this on every render; returning the previous element while nothing it shows + // changed keeps a selection change from re-rendering the hidden actions. + const rightActions = useRef<{ deps: readonly unknown[]; element: ReactNode } | null>(null); + const renderRightActions = ( + _progress: SharedValue, + translation: SharedValue, + methods: SwipeableMethods, + ) => { + const deps = [ + dormant, + translation, + methods, + actionStyle, + backgroundColor, + compactActions, + fullSwipeAction, + fullSwipeThreshold, + handleFullSwipeArmedChange, + primaryAction, + commitPrimaryAction, + onDelete, + secondaryAction, + threadTitle, + ]; + const cached = rightActions.current; + if (cached && deps.every((dep, index) => Object.is(dep, cached.deps[index]))) { + return cached.element; + } + const element = dormant ? null : ( + { + activeTranslationRef.current = translation; + }} + style={actionStyle} + > + methods.close(), + onDelete, + secondaryAction, + threadTitle, + })} + translation={translation} + /> + + ); + rightActions.current = { deps, element }; + return element; }; + const childrenContainerStyle = useMemo(() => ({ backgroundColor }), [backgroundColor]); + const swipeableContainerStyle = useMemo( + () => [{ backgroundColor }, containerStyle], + [backgroundColor, containerStyle], + ); return ( @@ -419,53 +488,21 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { { - if (swipeableRef.current) { - props.onSwipeableClose?.(swipeableRef.current); - } - }} + onSwipeableClose={handleSwipeableClose} onSwipeableRelease={handleRelease} onSwipeableOpenStartDrag={handleWillOpen} onSwipeableWillOpen={handleWillOpen} overshootFriction={1} overshootRight - renderRightActions={(_progress, translation, methods) => - props.dormant ? null : ( - { - activeTranslationRef.current = translation; - }} - style={actionStyle} - > - methods.close(), - onDelete: props.onDelete, - secondaryAction: props.secondaryAction, - threadTitle: props.threadTitle, - })} - translation={translation} - /> - - ) - } + renderRightActions={renderRightActions} rightThreshold={actionsWidth * 0.42} simultaneousWith={props.simultaneousWith} > diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 8de05e3118da..872f1f521f79 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -66,6 +66,7 @@ import { type WorkspaceDetail, type WorkspaceDetailSheet, } from "../workspace/workspace-detail-selection"; +import { startOpeningDetail } from "../workspace/useSelectWorkspaceDetail"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; @@ -498,6 +499,7 @@ function AdaptiveWorkspaceLayoutContent( type: "select", tab: detail.name === "Thread" ? "threads" : "pullRequests", }); + startOpeningDetail(detail); navigation.dispatch(selectWorkspaceDetail(detail, sheet)); }); } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 6b70637f7bd4..40cdfc1e3f83 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -499,6 +499,9 @@ function ThreadVisitWatermark(props: { return null; } +// Past the composer's 180 ms height animation. +const COMPOSER_HEIGHT_SETTLE_MS = 220; + export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetailScreenProps) { const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const deviceType = useDeviceType(); @@ -601,6 +604,24 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerHeight, setComposerHeight] = useState(null); + // The composer animates its height and reports a layout every frame. The feed follows those + // frames on the UI thread through onComposerLayout; this is only an estimate, so committing + // each frame would re-render the whole screen for the length of the animation. + const composerHeightSettle = useRef | null>(null); + const handleComposerHeightChange = useCallback((height: number) => { + setComposerHeight((current) => current ?? height); + if (composerHeightSettle.current !== null) clearTimeout(composerHeightSettle.current); + composerHeightSettle.current = setTimeout(() => { + composerHeightSettle.current = null; + setComposerHeight(height); + }, COMPOSER_HEIGHT_SETTLE_MS); + }, []); + useEffect( + () => () => { + if (composerHeightSettle.current !== null) clearTimeout(composerHeightSettle.current); + }, + [], + ); const [anchorMessageId, setAnchorMessageId] = useState(null); const [submittedMessageId, setSubmittedMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); @@ -1804,7 +1825,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai onUpdateModelSelection={props.onUpdateThreadModelSelection} onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} - onHeightChange={setComposerHeight} + onHeightChange={handleComposerHeightChange} onEditorFocusChange={handleOwnedInputFocusChange} /> diff --git a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts b/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts index a149212ba438..59d1ac1fd8dc 100644 --- a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts +++ b/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts @@ -1,11 +1,26 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { startOpeningEnvironmentThread } from "../../state/threads"; import { selectWorkspaceDetail, type WorkspaceDetail, type WorkspaceDetailSheet, } from "./workspace-detail-selection"; +const first = (value: string | readonly string[] | undefined) => + typeof value === "string" ? value : value?.[0]; + +/** Starts loading a selected thread before navigation commits; see `startOpeningEnvironmentThread`. */ +export function startOpeningDetail(detail: WorkspaceDetail) { + if (detail.name !== "Thread") return; + const environmentId = first(detail.params?.environmentId); + const threadId = first(detail.params?.threadId); + if (environmentId && threadId) { + startOpeningEnvironmentThread(EnvironmentId.make(environmentId), ThreadId.make(threadId)); + } +} + /** * Shows a page in the detail column the way selecting its list row does. Inside the workspace * layout this also settles its panes; outside it, the root stack's router still does the rest. @@ -15,7 +30,9 @@ export function useSelectWorkspaceDetail() { const { selectDetail } = useAdaptiveWorkspaceLayout(); return ( selectDetail ?? - ((detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => - navigation.dispatch(selectWorkspaceDetail(detail, sheet))) + ((detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => { + startOpeningDetail(detail); + navigation.dispatch(selectWorkspaceDetail(detail, sheet)); + }) ); } diff --git a/apps/mobile/src/features/workspace/workspace-selection-store.ts b/apps/mobile/src/features/workspace/workspace-selection-store.ts index aaabc0f1715e..e30533e47813 100644 --- a/apps/mobile/src/features/workspace/workspace-selection-store.ts +++ b/apps/mobile/src/features/workspace/workspace-selection-store.ts @@ -51,7 +51,7 @@ function save() { function withoutRouteKeys(saved: WorkspaceSelections | undefined): WorkspaceSelections { const omitKey = ({ key: _, ...rest }: Selection) => rest; - const { threads, pullRequests, settingsKey: _, ...rest } = saved ?? {}; + const { threads, pullRequests, settingsKey: _, threadsDraft: _draft, ...rest } = saved ?? {}; return { ...rest, ...(threads ? { threads: omitKey(threads) } : {}), diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts index a7c84f5f6d5d..a6f4298d268a 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts @@ -96,6 +96,30 @@ describe("opening a workspace tab", () => { expect(back.routes.at(-1)).toMatchObject({ key: "thread", params: thread.params }); }); + it("reopens a draft the tab showed after its last chat, until another chat opens", () => { + const columns = { columns: true, check: available }; + const draft = { key: "draft", name: "Thread", params: { draft: { requestId: "r" } } }; + const withDraft = workspaceSelectionsIn( + [home, draft], + workspaceSelectionsIn([home, thread], {}), + ); + const back = workspaceTabNavigationState( + { routes: [home, settings] }, + "threads", + withDraft, + columns, + ); + expect(back.routes.at(-1)).toEqual(draft); + const afterChat = workspaceSelectionsIn([home, thread], withDraft); + const again = workspaceTabNavigationState( + { routes: [home, settings] }, + "threads", + afterChat, + columns, + ); + expect(again.routes.at(-1)).toMatchObject({ key: "thread", params: thread.params }); + }); + it("falls back to the list for a chat that no longer exists, or an unknown Settings page", () => { const missing = { thread: () => false, pullRequest: () => true }; expect( diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts index 27c11686fe10..1603f7894fe0 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts @@ -91,9 +91,17 @@ export function workspaceSelectionsIn( threadId && (next.threads?.environmentId !== environmentId || next.threads.threadId !== threadId || - next.threads.key !== thread?.key) + next.threads.key !== thread?.key || + next.threadsDraft) ) { - next = { ...next, threads: { environmentId, threadId, key: thread?.key } }; + const { threadsDraft: _, ...rest } = next; + next = { ...rest, threads: { environmentId, threadId, key: thread?.key } }; + } else if ( + thread?.key !== undefined && + !threadId && + (next.threadsDraft?.key !== thread.key || next.threadsDraft.params !== thread.params) + ) { + next = { ...next, threadsDraft: { key: thread.key, params: thread.params } }; } const pullRequestRoute = routes.findLast((route) => route.name === "PullRequest"); const pullRequest = pullRequestSelection(pullRequestRoute?.params); @@ -136,6 +144,16 @@ function detailRoute( check: WorkspaceSelectionCheck, ): RouteLike | undefined { if (tab === "threads") { + const draft = selections.threadsDraft; + if (draft) { + return ( + state.routes.find((route) => route.key === draft.key) ?? { + key: draft.key, + name: "Thread", + params: draft.params as object | undefined, + } + ); + } const thread = selections.threads; if (!thread || !check.thread(thread)) return undefined; return ( diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index c5fd4f487fba..dda53132870e 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -71,6 +71,8 @@ const WorkspaceSelections = Schema.Struct({ ), settings: Schema.optional(Schema.String), settingsKey: Schema.optional(Schema.String), + /** A draft the threads tab showed after its last chat; only meaningful in its session. */ + threadsDraft: Schema.optional(Schema.Struct({ key: Schema.String, params: Schema.Unknown })), }); export type WorkspaceSelections = typeof WorkspaceSelections.Type; diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 2b7d4db5b99d..3f8be2e92905 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -46,6 +46,18 @@ export function useThreadFamily( }, [environmentId, rootThreadId]); } +// Long enough for the opened screen to subscribe; its subscription keeps the thread after this. +const OPENING_THREAD_HOLD_MS = 3_000; + +/** + * Starts loading a thread being opened before its screen renders and subscribes, which + * otherwise waits for navigation to commit. + */ +export function startOpeningEnvironmentThread(environmentId: EnvironmentId, threadId: ThreadId) { + const release = appAtomRegistry.mount(environmentThreads.stateAtom(environmentId, threadId)); + setTimeout(release, OPENING_THREAD_HOLD_MS); +} + const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("mobile-environment-thread:empty"), ); From 15702a9f28620588c0b71811d9d725e98789fdd4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:45:50 -0700 Subject: [PATCH 18/34] fix(mobile): reduce diff entry and source highlighting delays (#24770) Co-authored-by: Claude Opus 5.5 --- .../diffs/nativeReviewDiffSource.test.ts | 29 ++-- .../features/diffs/nativeReviewDiffSource.ts | 5 +- .../pull-requests/PullRequestRouteScreen.tsx | 33 ++++- .../pullRequestDiffHighlightFiles.test.ts | 12 +- .../pullRequestDiffPages.test.ts | 68 ++++++++- .../pull-requests/pullRequestDiffPages.ts | 40 ++++++ .../pull-requests/usePullRequestDiff.test.tsx | 132 +++++++++++++++++- .../pull-requests/usePullRequestDiff.ts | 57 ++++++-- .../pullRequest/PullRequestService.test.ts | 126 +++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 38 ++++- .../src/server/PullRequestProvider.ts | 14 ++ .../server/GitCafePullRequestProvider.test.ts | 118 ++++++++++++++++ .../src/server/GitCafePullRequestProvider.ts | 87 ++++++++---- 13 files changed, 684 insertions(+), 75 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts index ef2743a34d58..6c2cb8f0d3ad 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts @@ -58,17 +58,24 @@ const failureKeys = [ ["function", () => () => SOURCE], ] as const; -it.each(failureKeys)("bounds transient retries for a %s key", async (_name, createKey) => { - const key = createKey(); - const fetch = vi.fn(async () => { - throw new Error("Offline"); - }); - const load = createNativeReviewDiffSourceLoader(fetch); - await expect(load(key)).rejects.toThrow("Offline"); - expect(await load(key)).toBeNull(); - expect(await load(key)).toBeNull(); - expect(fetch).toHaveBeenCalledTimes(2); -}); +it.each(failureKeys)( + "recovers after two transient failures on a later load for a %s key", + async (_name, createKey) => { + const key = createKey(); + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error("Offline")) + .mockRejectedValueOnce(new Error("Still offline")) + .mockResolvedValue(SOURCE); + const load = createNativeReviewDiffSourceLoader(fetch); + await expect(load(key)).rejects.toThrow("Offline"); + await expect(load(key)).rejects.toThrow("Still offline"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await load(key)).toBe(SOURCE); + expect(await load(key)).toBe(SOURCE); + expect(fetch).toHaveBeenCalledTimes(3); + }, +); it.each(failureKeys)( "remembers permanent unavailability for a %s key", diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts index 8dc30c718253..7f503239c21d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts @@ -5,7 +5,6 @@ const MAX_SOURCE_FILES = 4; // Bound retained UTF-16 text across files without discarding an already-fetched // authoritative source just because it is large. Provider response limits still apply. const MAX_CACHED_SOURCE_CHARACTERS = 8_000_000; -const MAX_SOURCE_ATTEMPTS = 2; function isWeakKey(value: unknown): value is object { return (typeof value === "object" && value !== null) || typeof value === "function"; @@ -158,12 +157,12 @@ export function createNativeReviewDiffSourceLoader( permanent, reason: reason ?? "transient", }); - if (permanent || attempt >= MAX_SOURCE_ATTEMPTS) { + if (permanent) { failure.unavailable = true; traceDiffHighlight("source-unavailable", { loader, file, - reason: permanent ? "permanent-error" : "attempts", + reason: "permanent-error", }); return null; } diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index e988da1a0af1..190d707cc8cf 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -43,6 +43,7 @@ import { import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles"; import { isPullRequestTestFile } from "./pullRequestFileFilters"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; @@ -88,7 +89,7 @@ import { type NativeReviewDiffViewProps, } from "../diffs/nativeReviewDiffSurface"; import { - buildNativeReviewDiffData, + getCachedNativeReviewDiffData, NATIVE_REVIEW_DIFF_CONTENT_WIDTH, } from "../review/nativeReviewDiffAdapter"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -99,6 +100,7 @@ import { PullRequestActionToolbar, PullRequestCardGroup } from "./PullRequestCon import { pullRequestTarget as requestTarget, usePullRequestDiff, + usePullRequestDiffEntry, type PullRequestDiffParams, type PullRequestParams, } from "./usePullRequestDiff"; @@ -847,16 +849,35 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps) { +>; + +export function PullRequestFileRouteScreen(props: PullRequestFileRouteProps) { + const navigation = + useNavigation>(); + const ready = usePullRequestDiffEntry(navigation); + if (ready) return ; + const title = props.route.params.commit ? "Commit changes" : "Changes"; + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + + ); +} + +function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -894,7 +915,7 @@ export function PullRequestFileRouteScreen({ !result.isPending && !result.error; const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(route.params))); - const baseData = useMemo(() => buildNativeReviewDiffData(parsed), [parsed]); + const baseData = useMemo(() => getCachedNativeReviewDiffData({ parsedDiff: parsed }), [parsed]); const highlightFiles = usePullRequestDiffHighlightFiles(route.params, parsed, baseData.files); const data = useMemo( () => diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts index 3d419f262da3..b48933573f36 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts @@ -265,8 +265,12 @@ it.each(["permanent", "transient"])( fetch.mockRejectedValueOnce(error); if (failure === "transient") fetch.mockRejectedValueOnce(error); const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); - if (failure === "transient") await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); - expect(await first[1]!.loadContents!()).toBeNull(); + if (failure === "transient") { + await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); + await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); + } else { + expect(await first[1]!.loadContents!()).toBeNull(); + } const second = createPullRequestDiffHighlightFiles( { target }, { ...parsed, files: [parsed.files[0]!, { ...parsed.files[1]! }] }, @@ -275,8 +279,8 @@ it.each(["permanent", "transient"])( cache, ); expect(await second[1]!.loadContents!()).toBe(contents); - expect(await first[1]!.loadContents!()).toBeNull(); - expect(fetch).toHaveBeenCalledTimes(failure === "permanent" ? 2 : 3); + expect(await first[1]!.loadContents!()).toBe(failure === "permanent" ? null : contents); + expect(fetch).toHaveBeenCalledTimes(failure === "permanent" ? 2 : 4); }, ); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts index a2d06d06ca0c..ee5f02443074 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PullRequestDiffResult } from "@t3tools/contracts"; import type { ReviewParsedDiff } from "../review/reviewModel"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { combinePullRequestDiffPages, retainPullRequestDiffPage } from "./pullRequestDiffPages"; +import * as reviewModel from "../review/reviewModel"; +import { + combinePullRequestDiffPages, + getCachedPullRequestDiffPages, + retainPullRequestDiffPage, +} from "./pullRequestDiffPages"; const GITHUB = sourceControlClients.get("github").changeRequest; @@ -24,6 +29,65 @@ function withheldDiff(): PullRequestDiffResult { } describe("paged mobile pull request diffs", () => { + it("isolates scopes, cursors, and provider terminology for the same result", () => { + const result = withheldDiff(); + const pages = [{ cursor: null, result }]; + const first = getCachedPullRequestDiffPages(pages, "first", GITHUB); + const otherScope = getCachedPullRequestDiffPages(pages, "second", GITHUB); + const otherCursor = getCachedPullRequestDiffPages( + [{ cursor: "next", result }], + "first", + GITHUB, + ); + const otherProvider = getCachedPullRequestDiffPages( + pages, + "first", + sourceControlClients.get("gitlab").changeRequest, + ); + expect(otherScope).not.toBe(first); + expect(otherCursor).not.toBe(first); + expect(otherProvider).not.toBe(first); + if (first.kind !== "files" || otherScope.kind !== "files" || otherProvider.kind !== "files") + throw new Error("Expected structured files"); + expect(otherScope.files[0]?.id).not.toBe(first.files[0]?.id); + expect(first.notice).toContain("pull request"); + expect(otherProvider.notice).toContain("merge request"); + expect(getCachedPullRequestDiffPages(pages, "first", GITHUB)).toBe(first); + }); + + it("reuses earlier pages on append and invalidates changed continuation results", () => { + using parse = vi.spyOn(reviewModel, "buildReviewParsedDiff"); + const firstPage = { cursor: null, result: diff("first.ts") }; + const first = getCachedPullRequestDiffPages([firstPage], "append", GITHUB); + const nextPage = { cursor: "next", result: withheldDiff() }; + const combined = getCachedPullRequestDiffPages([firstPage, nextPage], "append", GITHUB); + expect(parse).toHaveBeenCalledTimes(2); + if (first.kind !== "files" || combined.kind !== "files") throw new Error("Expected files"); + expect(combined.files[0]).toBe(first.files[0]); + expect(combined.files.map((file) => file.path)).toEqual(["first.ts", "large.ts"]); + expect(getCachedPullRequestDiffPages([firstPage, nextPage], "append", GITHUB)).toBe(combined); + const refreshed = getCachedPullRequestDiffPages( + [ + firstPage, + { + ...nextPage, + result: { + ...nextPage.result, + omittedFileStats: [{ path: "large.ts", additions: 4000, deletions: 2000 }], + }, + }, + ], + "append", + GITHUB, + ); + expect(parse).toHaveBeenCalledTimes(3); + expect(refreshed).not.toBe(combined); + if (refreshed.kind !== "files") throw new Error("Expected refreshed files"); + expect(refreshed.files[0]).toBe(first.files[0]); + expect(refreshed.files[1]).toMatchObject({ additions: 4000, deletions: 2000 }); + expect(combined.files[1]?.additions).toBe(3000); + }); + it("withheld PR hunks must retain provider line counts", () => { const result = withheldDiff(); const cache = new WeakMap(); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts index c30f3a3198ad..9abf7cfc0c07 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts @@ -7,6 +7,46 @@ export interface PullRequestDiffPage { readonly result: PullRequestDiffResult; } +interface CachedPullRequestDiffPages { + readonly pages: readonly PullRequestDiffPage[]; + readonly cache: WeakMap; + readonly parsed: ReviewParsedDiff; +} + +// Keep only the latest combination per scoped first page; query results own its lifetime. +const parsedSnapshots = new WeakMap< + PullRequestDiffResult, + Map +>(); + +export function getCachedPullRequestDiffPages( + pages: readonly PullRequestDiffPage[], + scope: string, + terminology: ChangeRequestTerminology, +): ReviewParsedDiff { + const first = pages[0]; + if (!first) return { kind: "empty" }; + const key = JSON.stringify([scope, terminology.singular, first.cursor]); + let snapshots = parsedSnapshots.get(first.result); + if (!snapshots) { + snapshots = new Map(); + parsedSnapshots.set(first.result, snapshots); + } + const previous = snapshots.get(key); + if ( + previous?.pages.length === pages.length && + pages.every((page, index) => { + const cached = previous.pages[index]; + return cached?.cursor === page.cursor && cached.result === page.result; + }) + ) + return previous.parsed; + const cache = previous?.cache ?? new WeakMap(); + const parsed = combinePullRequestDiffPages(pages, cache, scope, terminology); + snapshots.set(key, { pages, cache, parsed }); + return parsed; +} + /** A changed page invalidates later cursors, which belong to the previous patch. */ export function retainPullRequestDiffPage( pages: readonly PullRequestDiffPage[], diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx index 0548cbb24192..3000efb9633b 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx @@ -3,14 +3,16 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDiffResult } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { act, useLayoutEffect } from "react"; +import { act, useLayoutEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ diff: vi.fn() })); vi.mock("../../state/pull-requests", () => ({ composerPullRequests: { diff: mocks.diff } })); -import { usePullRequestDiff } from "./usePullRequestDiff"; +import { usePullRequestDiff, usePullRequestDiffEntry } from "./usePullRequestDiff"; +import * as reviewModel from "../review/reviewModel"; +import { getCachedNativeReviewDiffData } from "../review/nativeReviewDiffAdapter"; function patch(path: string, nextCursor: string | null = null): PullRequestDiffResult { return { @@ -67,6 +69,15 @@ function setup() { ); return { requests, + remount() { + act(() => + root.render( + + + , + ), + ); + }, get current() { return current; }, @@ -87,6 +98,123 @@ function setup() { }; } +it("reuses the parsed snapshot when a new route reads the same query result", async () => { + using parse = vi.spyOn(reviewModel, "buildReviewParsedDiff"); + using screen = setup(); + await screen.succeed(0, patch("first.ts")); + const parsed = screen.current.parsed; + const parses = parse.mock.calls.length; + screen.remount(); + expect(screen.current.parsed).toBe(parsed); + expect(parse).toHaveBeenCalledTimes(parses); + expect(screen.requests).toHaveLength(1); +}); + +function setupEntry() { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const frames = new Map(); + let frameId = 0; + const requestFrame = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation((callback) => { + frames.set(++frameId, callback); + return frameId; + }); + const cancelFrame = vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation((id) => { + if (id != null) frames.delete(id); + }); + const listeners = new Map void>(); + const navigation = { + addListener( + type: "transitionStart" | "transitionEnd", + listener: (event: { data: { closing: boolean } }) => void, + ) { + listeners.set(type, listener); + return () => { + listeners.delete(type); + }; + }, + }; + const events: string[] = []; + const prepare = vi.fn(() => { + events.push("prepare"); + return getCachedNativeReviewDiffData({ + parsedDiff: reviewModel.buildReviewParsedDiff( + patch("first.ts").patch + patch("neighbor.ts").patch, + "entry", + ), + }); + }); + function Body() { + const [data] = useState(prepare); + return
{data.files.map((file) => file.path).join(",")}
; + } + function Entry() { + const ready = usePullRequestDiffEntry(navigation); + useLayoutEffect(() => { + events.push(ready ? "body-commit" : "shell-commit"); + }, [ready]); + return ready ? :
Opening changes
; + } + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render()); + return { + prepare, + events, + container, + frame() { + const pending = [...frames.values()]; + frames.clear(); + act(() => { + for (const callback of pending) callback(0); + }); + }, + event(type: "transitionStart" | "transitionEnd", closing = false) { + act(() => listeners.get(type)?.({ data: { closing } })); + }, + fallback() { + act(() => vi.runAllTimers()); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + requestFrame.mockRestore(); + cancelFrame.mockRestore(); + vi.useRealTimers(); + }, + }; +} + +it("commits the loading shell and waits for the opening transition before building the diff", () => { + using screen = setupEntry(); + expect(screen.events).toEqual(["shell-commit"]); + screen.event("transitionStart"); + screen.frame(); + screen.frame(); + screen.event("transitionEnd", true); + expect(screen.prepare).not.toHaveBeenCalled(); + screen.event("transitionEnd"); + expect(screen.events).toEqual(["shell-commit", "prepare", "body-commit"]); + expect(screen.container.textContent).toBe("first.ts,neighbor.ts"); +}); + +it.each(["unanimated", "missing-completion"] as const)( + "opens the diff after a %s presentation without stranding the shell", + (presentation) => { + using screen = setupEntry(); + if (presentation === "missing-completion") screen.event("transitionStart"); + screen.frame(); + expect(screen.prepare).not.toHaveBeenCalled(); + screen.frame(); + if (presentation === "missing-completion") { + expect(screen.prepare).not.toHaveBeenCalled(); + screen.fallback(); + } + expect(screen.prepare).toHaveBeenCalledOnce(); + }, +); + it("retries a failed continuation without discarding loaded files or restarting page one", async () => { using screen = setup(); await screen.succeed(0, patch("first.ts", "second")); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index 2b33c86871ac..c3c25b859193 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -1,19 +1,13 @@ import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; -import { - EnvironmentId, - ProjectId, - type PullRequestDiffResult, - type PullRequestListEntry, -} from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, type PullRequestListEntry } from "@t3tools/contracts"; import { useAtomRefresh } from "@effect/atom-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; import { usePullToRefresh } from "../../state/use-pull-to-refresh"; -import type { ReviewParsedDiff } from "../review/reviewModel"; import { - combinePullRequestDiffPages, + getCachedPullRequestDiffPages, retainPullRequestDiffPage, type PullRequestDiffPage, } from "./pullRequestDiffPages"; @@ -43,6 +37,46 @@ export function pullRequestTarget(params: PullRequestParams) { const NO_PAGES: readonly PullRequestDiffPage[] = []; +export function usePullRequestDiffEntry(navigation: { + readonly addListener: ( + type: "transitionStart" | "transitionEnd", + listener: (event: { readonly data: { readonly closing: boolean } }) => void, + ) => () => void; +}) { + const [ready, setReady] = useState(false); + useLayoutEffect(() => { + if (ready) return; + let opening = false; + let closing = false; + const finish = () => { + if (!closing) setReady(true); + }; + const removeStart = navigation.addListener("transitionStart", ({ data }) => { + closing = data.closing; + if (!closing) opening = true; + }); + const removeEnd = navigation.addListener("transitionEnd", ({ data }) => { + closing = data.closing; + if (!closing) finish(); + }); + // Initial and unanimated presentations may have no transition events; let the shell paint first. + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + if (!opening) finish(); + }); + }); + // A missing native completion must not strand the route on its loading state. + const fallback = setTimeout(finish, 1_000); + return () => { + removeStart(); + removeEnd(); + cancelAnimationFrame(frame); + clearTimeout(fallback); + }; + }, [navigation, ready]); + return ready; +} + export function usePullRequestDiff( params: PullRequestDiffParams, terminology: ChangeRequestTerminology, @@ -74,10 +108,9 @@ export function usePullRequestDiff( }, [cursor, key, query.data, state.key, state.pages]); if (state.key !== key || state.pages !== pages) setState({ key, cursor, pages }); - const [parseCache] = useState(() => new WeakMap()); const parsed = useMemo( - () => combinePullRequestDiffPages(pages, parseCache, key, terminology), - [key, pages, parseCache, terminology], + () => getCachedPullRequestDiffPages(pages, key, terminology), + [key, pages, terminology], ); const patch = useMemo(() => pages.map((page) => page.result.patch).join("\n"), [pages]); const nextCursor = pages.at(-1)?.result.nextCursor ?? null; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index a17347d8347f..b3984ea10547 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3981,6 +3981,132 @@ it.effect("pauses uncached activity on a host after one activity read is rate li }), ); +it.effect("authorizes cold source reads with paginated paths without fetching patch hunks", () => + Effect.gen(function* () { + const pages: Array = []; + const contents: Array = []; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: () => Effect.die("Membership must not fetch patch hunks"), + getDiffFilePaths: (input) => + Effect.sync(() => { + pages.push(input.cursor); + return input.cursor === undefined + ? { files: [{ oldPath: "before.ts", newPath: "after.ts" }], nextCursor: "next" } + : { files: [{ oldPath: "gone.ts", newPath: "gone.ts" }], nextCursor: null }; + }), + getDiffFileContents: (input) => + Effect.sync(() => { + contents.push(`${input.oldPath}->${input.newPath}`); + return { oldContents: "before", newContents: "after" }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.diffFileContents({ + ...ref, + changeType: "rename-changed", + oldPath: "before.ts", + newPath: "after.ts", + }); + yield* service.diffFileContents({ + ...ref, + changeType: "deleted", + oldPath: "gone.ts", + newPath: "gone.ts", + }); + for (const [oldPath, newPath] of [ + [".env", ".env"], + ["../after.ts", "../after.ts"], + ["secret.ts", "after.ts"], + ] as const) { + const error = yield* service + .diffFileContents({ ...ref, changeType: "change", oldPath, newPath }) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + } + assert.deepStrictEqual(pages, [undefined, "next"]); + assert.deepStrictEqual(contents, ["before.ts->after.ts", "gone.ts->gone.ts"]); + }), +); + +it.effect.each(["commit", "credential", "expiry", "refresh"] as const)( + "keeps cold metadata membership in its original scope through an in-flight %s", + (boundary) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let credential = "broad"; + let pathReads = 0; + let contentReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + withVerifiedCredential: (_, use) => + Effect.suspend(() => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: credential }), + ), + getDiff: () => Effect.die("Membership must not fetch patch hunks"), + getDiffFilePaths: (input) => + Effect.gen(function* () { + assert.strictEqual(input.cwd, "/a"); + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.repository, "acme/web"); + if (++pathReads !== 1) return { files: [], nextCursor: null }; + assert.strictEqual( + input.commit, + boundary === "commit" ? "a".repeat(40) : undefined, + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { + files: [{ oldPath: "private.ts", newPath: "private.ts" }], + nextCursor: null, + }; + }), + getDiffFileContents: () => + Effect.sync(() => { + contentReads += 1; + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "101", + changeType: "change" as const, + oldPath: "private.ts", + newPath: "private.ts", + }; + const initial = boundary === "commit" ? { ...input, commit: "a".repeat(40) } : input; + const loading = yield* Effect.forkChild( + service.withRoutingCredential(initial, service.diffFileContents(initial)), + ); + yield* Deferred.await(started); + if (boundary === "expiry") yield* TestClock.adjust("61 seconds"); + if (boundary === "refresh") yield* service.invalidate({ reference: input }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(loading); + if (boundary === "credential") credential = "restricted"; + const error = yield* service + .withRoutingCredential(input, service.diffFileContents(input)) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(pathReads, 2); + assert.strictEqual(contentReads, 1); + }), +); + it.effect( "reuses loaded oversized diff slices to expand only files the change request changes", () => diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 437f8ab4e52d..b37cf7d57667 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -86,6 +86,7 @@ import type { SourceControlProviderContext } from "@t3tools/source-control-core/ import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { type ProviderChangeRequest, + type ProviderDiffFilePaths, type ProviderFilterOptionPage, type ProviderListCursor, type ProviderChangeRequestWatchFingerprint, @@ -543,6 +544,9 @@ function withRateLimitBackoff( }), getViewerPermissions: interactive("getViewerPermissions", api.getViewerPermissions), getDiff: wrap("getDiff", api.getDiff), + ...(api.getDiffFilePaths === undefined + ? {} + : { getDiffFilePaths: wrap("getDiffFilePaths", api.getDiffFilePaths) }), ...(api.getDiffFileContents === undefined ? {} : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), @@ -3475,14 +3479,10 @@ export const make = Effect.gen(function* () { const fileKey = (oldPath: string, newPath: string) => JSON.stringify([oldPath, newPath]); const sliceKey = (input: PullRequestDiffInput) => JSON.stringify([refCacheKey(input), input.cursor ?? null, input.commit ?? null]); - const record = (key: string, slice: PullRequestDiffResult, at: number) => { + const recordPaths = (key: string, slice: ProviderDiffFilePaths, at: number) => { const entry = { at, - files: new Set( - parseDiffFilePaths(slice.patch).map((file) => - fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), - ), - ), + files: new Set(slice.files.map((file) => fileKey(file.oldPath, file.newPath))), nextCursor: slice.nextCursor, }; held.delete(key); @@ -3493,11 +3493,37 @@ export const make = Effect.gen(function* () { held.set(key, entry); return entry; }; + const record = (key: string, slice: PullRequestDiffResult, at: number) => + recordPaths( + key, + { + files: parseDiffFilePaths(slice.patch).map((file) => ({ + oldPath: file.oldPath ?? file.newPath ?? "", + newPath: file.newPath ?? file.oldPath ?? "", + })), + nextCursor: slice.nextCursor, + }, + at, + ); const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { const key = sliceKey(input); const now = yield* Clock.currentTimeMillis; const cached = held.get(key); if (cached !== undefined && now - cached.at <= ttlMs) return cached; + const project = yield* requireProject(input); + if (project.api.capabilities.diff && project.api.getDiffFilePaths) { + const slice = yield* project.api + .getDiffFilePaths({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe(Effect.mapError(toPullRequestError("diffFileContents"))); + return recordPaths(key, slice, now); + } const slice = yield* diff(input); return record(key, slice, now); }); diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index ae64bda0395f..ce1a72c1aedf 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -223,6 +223,11 @@ export interface ProviderDiffFileContents { readonly newContents: string; } +export interface ProviderDiffFilePaths { + readonly files: ReadonlyArray<{ readonly oldPath: string; readonly newPath: string }>; + readonly nextCursor: string | null; +} + export interface ProviderFilesViewed { readonly files: ReadonlyArray; /** More files exist; missing entries are unknown, not "unviewed". */ @@ -394,6 +399,15 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** Exact changed path pairs, including renames, without fetching patch contents. */ + readonly getDiffFilePaths?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly cursor?: string | undefined; + readonly commit?: string | undefined; + }, + ) => Effect.Effect; + readonly getDiffFileContents?: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index 40430ab2f152..d787b2ba62ac 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -164,6 +164,124 @@ const assertRequestId = (body: unknown) => /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, ); +describe("GitCafePullRequestProvider changed paths", () => { + const snapshot = { version: pull.version, headOid, comparisonBaseOid: baseOid }; + + it.effect("pages changed paths without fetching diff-file hunks", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": (request: SentRequest) => ({ + ...snapshot, + items: request.query.has("after") + ? [{ path: "gone.ts", status: "deleted" }] + : [{ path: "new name.ts", oldPath: "old name.ts", status: "renamed" }], + next: request.query.has("after") ? null : "next-page", + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const first = yield* provider.getDiffFilePaths!(target); + const second = yield* provider.getDiffFilePaths!({ ...target, cursor: first.nextCursor! }); + assert.deepStrictEqual(first.files, [{ oldPath: "old name.ts", newPath: "new name.ts" }]); + assert.deepStrictEqual(second, { + files: [{ oldPath: "gone.ts", newPath: "gone.ts" }], + nextCursor: null, + }); + assert.isFalse(server.sent.some((request) => request.path.includes("/diff-file"))); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each(["getDiff", "getDiffFilePaths"] as const)( + "%s rejects a repeated continuation cursor", + (method) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [], next: "same-page" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const first = yield* provider[method]!(target); + const input = { ...target, cursor: first.nextCursor! }; + const result = + method === "getDiff" + ? yield* provider.getDiff(input).pipe(Effect.result) + : yield* provider.getDiffFilePaths!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + // getDiff cases guard parity with the existing patch path; getDiffFilePaths must refuse the same pages. + it.effect.each( + (["getDiff", "getDiffFilePaths"] as const).flatMap((method) => + [{ version: 5 }, { headOid: baseOid }, { comparisonBaseOid: headOid }].map((changed) => ({ + method, + changed, + })), + ), + )( + "$method refuses a continuation from a different revision ($changed)", + ({ method, changed }) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": (request: SentRequest) => ({ + ...snapshot, + ...(request.query.has("after") ? changed : {}), + items: [], + next: request.query.has("after") ? null : "next-page", + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const read = (cursor?: string) => + method === "getDiff" + ? provider.getDiff({ ...target, ...(cursor === undefined ? {} : { cursor }) }) + : provider.getDiffFilePaths!({ + ...target, + ...(cursor === undefined ? {} : { cursor }), + }); + const first = yield* read(); + const input = { ...target, cursor: first.nextCursor! }; + const result = + method === "getDiff" + ? yield* provider.getDiff(input).pipe(Effect.result) + : yield* provider.getDiffFilePaths!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each(["getDiff", "getDiffFilePaths"] as const)( + "%s reads a pull that reports no head yet", + (method) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, headOid: null }, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider[method]!(target); + assert.isNull(result.nextCursor); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each([{ version: 5 }, { headOid: baseOid }])( + "refuses initial changed paths from a different pull revision (%j)", + (changed) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, ...changed, items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFilePaths!(target).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); +}); + describe("GitCafePullRequestProvider source contents", () => { const comparisonBase = "a".repeat(40); const snapshot = { version: pull.version, headOid, comparisonBaseOid: comparisonBase }; diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 30e9296d36e5..f2aa82fdcb66 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -56,6 +56,8 @@ const AUTHOR_DISCOVERY_PAGES = 5; const KNOWN_AUTHORS_LIMIT = 10_000; /** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ const DIFF_FILES_BATCH = 64; +/** Changed-path pages carry no hunks, so they can be read in larger pages. */ +const CHANGES_PAGE_LIMIT = 500; /** Line totals require hunk reads; omit them for large PRs. */ const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; const MAX_CHANGE_PAGES = 20; @@ -522,6 +524,45 @@ export const make = Effect.gen(function* () { return { ...stack, members }; }); + const readDiffPage = Effect.fn("GitCafePullRequestProvider.readDiffPage")(function* ( + operation: string, + input: Parameters[0], + limit: number, + ) { + if (input.commit !== undefined) + return yield* failure(operation, "GitCafe commit diffs are not supported yet."); + const cursor = + input.cursor === undefined + ? null + : yield* decodeDiffCursor(input.cursor).pipe( + Effect.mapError((cause) => failure(operation, "Invalid GitCafe diff cursor.", cause)), + ); + // A pull without a head yet has nothing to pin; its version still is. + const expected = cursor ?? (yield* readPull(operation, input)); + const version = expected.version; + const path = yield* pullPath(operation, input); + const query = new URLSearchParams({ expectedVersion: String(version), limit: String(limit) }); + if (cursor !== null) query.set("after", cursor.after); + const page = yield* read(operation, input, { path: `${path}/changes?${query}` }, Changes); + if ( + page.version !== version || + (expected.headOid !== null && page.headOid !== expected.headOid) || + (cursor !== null && cursor.baseOid !== page.comparisonBaseOid) + ) + return yield* failure( + operation, + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + if (cursor !== null && page.next !== null && page.next === cursor.after) + return yield* failure(operation, "GitCafe returned a repeated diff page cursor."); + const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; + return { + items: page.items, + revision, + nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + }; + }); + const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( input: PullRef, revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, @@ -605,7 +646,10 @@ export const make = Effect.gen(function* () { let revision: { version: number; headOid: string; baseOid: string } | undefined; let after: string | null = null; for (let page = 0; page < MAX_CHANGE_PAGES; page++) { - const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" }); + const query = new URLSearchParams({ + expectedVersion: String(version), + limit: String(CHANGES_PAGE_LIMIT), + }); if (after !== null) query.set("after", after); const batch: typeof Changes.Type = yield* read( "getChangeRequest", @@ -966,36 +1010,21 @@ export const make = Effect.gen(function* () { getViewerPermissions: (input) => readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)), getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) { - if (input.commit !== undefined) - return yield* failure("getDiff", "GitCafe commit diffs are not supported yet."); - const cursor = - input.cursor === undefined - ? null - : yield* decodeDiffCursor(input.cursor).pipe( - Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)), - ); - const version = cursor?.version ?? (yield* readPull("getDiff", input)).version; - const path = yield* pullPath("getDiff", input); - const query = new URLSearchParams({ - expectedVersion: String(version), - limit: String(DIFF_FILES_BATCH), - }); - if (cursor !== null) query.set("after", cursor.after); - const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes); - if ( - page.version !== version || - (cursor !== null && - (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid)) - ) - return yield* failure( - "getDiff", - "GitCafe answered from a different revision of the pull request. Refresh the diff.", - ); - const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; - const details = yield* readDiffFiles(input, revision, detailablePaths(page.items)); + const page = yield* readDiffPage("getDiff", input, DIFF_FILES_BATCH); + const details = yield* readDiffFiles(input, page.revision, detailablePaths(page.items)); return { ...Json.toDiff({ items: withDetails(page.items, details) }), - nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + nextCursor: page.nextCursor, + }; + }), + getDiffFilePaths: Effect.fn("GitCafePullRequestProvider.getDiffFilePaths")(function* (input) { + const page = yield* readDiffPage("getDiffFilePaths", input, CHANGES_PAGE_LIMIT); + return { + files: page.items.map((file) => ({ + oldPath: file.oldPath ?? file.path, + newPath: file.path, + })), + nextCursor: page.nextCursor, }; }), getDiffFileContents: Effect.fn("GitCafePullRequestProvider.getDiffFileContents")( From 17dc1afa15980c72a790271034e3cb13ccf810c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:53:32 -0700 Subject: [PATCH 19/34] perf(client): skip unchanged shell and config cache writes (#24266) --- .../client-runtime/src/state/server.test.ts | 258 ++++++++++++++++++ packages/client-runtime/src/state/server.ts | 50 +++- .../src/state/shell-sync.test.ts | 150 ++++++++++ packages/client-runtime/src/state/shell.ts | 13 + .../client-runtime/src/state/shellReducer.ts | 38 +-- 5 files changed, 488 insertions(+), 21 deletions(-) diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 931884a92c1b..09b28c539d33 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,5 +1,8 @@ import { EnvironmentId, + ServerObservability, + EnvironmentThemeId, + UsageLimitSourceId, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, @@ -15,8 +18,10 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { RpcClientError } from "effect/rpc"; import * as Socket from "effect/socket/Socket"; @@ -207,7 +212,260 @@ describe("update restart reconnect nudges", () => { ); }); +const makeConfigPersistenceHarness = Effect.fn("test.makeConfigPersistenceHarness")(function* ( + saveServerConfig: Persistence.EnvironmentCacheStore["Service"]["saveServerConfig"], +) { + const events = yield* Queue.unbounded(); + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedSome(CONFIG), + saveServerConfig, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const state = yield* makeEnvironmentServerConfigState({ + environmentThemes: true, + usageLimitSources: true, + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + const send = Effect.fn("test.sendConfigEvent")(function* (event: ServerConfigStreamEvent) { + yield* Queue.offer(events, event); + return yield* SubscriptionRef.changes(state).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.filter((projection) => projection.latestEvent === event), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + }); + return { send }; +}); + describe("server state projection", () => { + it.effect("skips duplicate keybinding saves but persists changed keybindings", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const keybindingsEvent = (key: string): ServerConfigStreamEvent => ({ + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [ + { + command: "terminal.toggle", + shortcut: { + key, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + ], + issues: [], + }, + }); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Ref.update(saved, (values) => [...values, config]), + ); + const first = yield* h.send(keybindingsEvent("p")); + yield* TestClock.adjust("500 millis"); + expect(yield* Ref.get(saved)).toEqual([first.config]); + + yield* h.send(keybindingsEvent("p")); + yield* TestClock.adjust("10 seconds"); + expect(yield* Ref.get(saved)).toEqual([first.config]); + + const changed = yield* h.send(keybindingsEvent("q")); + yield* TestClock.adjust("10 seconds"); + expect(yield* Ref.get(saved)).toEqual([first.config, changed.config]); + }), + ); + }), + ); + + it.effect( + "compares nested durable values independently of key order and omitted undefined fields", + () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const observability = { + logsDirectoryPath: "/synthetic/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + otlpLogsEnabled: false, + }; + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Ref.update(saved, (values) => [...values, config]), + ); + const first = yield* h.send(snapshotEvent({ ...CONFIG, observability })); + yield* TestClock.adjust("500 millis"); + const reordered = Object.fromEntries( + Object.entries(observability).toReversed(), + ) as typeof observability; + for (const next of [ + { ...CONFIG, observability: reordered }, + { ...CONFIG, observability: { ...reordered, otlpTracesUrl: undefined } }, + { ...CONFIG, observability: { ...observability } }, + ]) { + const encode = Schema.encodeEffect(Schema.fromJsonString(ServerObservability)); + expect(JSON.parse(yield* encode(next.observability))).toEqual( + JSON.parse(yield* encode(observability)), + ); + const projection = yield* h.send(snapshotEvent(next)); + expect(projection.config.observability).toEqual(next.observability); + yield* TestClock.adjust("10 seconds"); + expect.soft(yield* Ref.get(saved)).toEqual([first.config]); + } + const writesBeforeChange = (yield* Ref.get(saved)).length; + const changed = yield* h.send( + snapshotEvent({ + ...CONFIG, + observability: { ...observability, otlpTracesUrl: "https://traces.example.test" }, + }), + ); + yield* TestClock.adjust("10 seconds"); + expect((yield* Ref.get(saved)).length).toBe(writesBeforeChange + 1); + expect((yield* Ref.get(saved)).at(-1)).toEqual(changed.config); + }), + ); + }), + ); + + it.effect("saves once across transient themes, usage sources and stall updates", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Ref.update(saved, (values) => [...values, config]), + ); + yield* h.send(snapshotEvent(CONFIG)); + yield* TestClock.adjust("500 millis"); + expect((yield* Ref.get(saved)).length).toBe(1); + for (const event of [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { + themes: [ + { + id: EnvironmentThemeId.make("theme-test"), + name: "Test theme", + appearance: "dark", + }, + ], + }, + }, + { + version: 1, + type: "usageLimitSourcesUpdated", + payload: { + sources: [ + { + id: UsageLimitSourceId.make("source-test"), + kind: "cliproxy", + label: "Test source", + checkedAt: "2026-10-20T07:50:37.633Z", + accounts: [], + }, + ], + }, + }, + snapshotEvent({ + ...CONFIG, + lastEventLoopStall: { + detectedAt: "2026-10-20T07:50:37.633Z", + durationMs: 1000, + loadAverage: 1, + cpuCount: 8, + }, + }), + ] satisfies ReadonlyArray) { + const projection = yield* h.send(event); + expect(projection.latestEvent).toBe(event); + if (event.type === "environmentThemesUpdated") + expect(projection.config.environmentThemes).toEqual(event.payload.themes); + if (event.type === "usageLimitSourcesUpdated") + expect(projection.config.usageLimitSources).toEqual(event.payload.sources); + yield* TestClock.adjust("10 seconds"); + } + }), + ); + expect(yield* Ref.get(saved)).toEqual([CONFIG]); + }), + ); + + it.effect("flushes pending durable changes after a transient event", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const changed = { ...CONFIG, keybindingsConfigPath: "/changed/keybindings.json" }; + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Ref.update(saved, (values) => [...values, config]), + ); + yield* h.send(snapshotEvent(CONFIG)); + yield* TestClock.adjust("500 millis"); + yield* h.send(snapshotEvent(changed)); + yield* h.send({ version: 1, type: "environmentThemesUpdated", payload: { themes: [] } }); + }), + ); + expect(yield* Ref.get(saved)).toEqual([CONFIG, changed]); + }), + ); + + it.effect("retries a failed durable save on a transient event", () => + Effect.gen(function* () { + const attempts = yield* Ref.make>([]); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Effect.gen(function* () { + const values = yield* Ref.updateAndGet(attempts, (values) => [...values, config]); + if (values.length === 1) + return yield* new Persistence.ConnectionPersistenceError({ + operation: "save-server-config", + message: "test failure", + }); + }), + ); + yield* h.send(snapshotEvent(CONFIG)); + yield* TestClock.adjust("500 millis"); + yield* h.send({ version: 1, type: "usageLimitSourcesUpdated", payload: { sources: [] } }); + yield* TestClock.adjust("10 seconds"); + }), + ); + expect(yield* Ref.get(attempts)).toEqual([CONFIG, CONFIG]); + }), + ); + it("only treats a legacy transport interruption as an unacknowledged handoff", () => { expect(isLegacyUpdateHandoffLoss(Cause.interrupt(1))).toBe(true); expect( diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 77c5beb61a40..7913f8fe84e2 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -358,6 +358,41 @@ export interface ServerConfigSubscriptionOptions { readonly usageLimitsCommand?: boolean; } +// Configs are JSON-shaped. Missing and undefined object fields persist identically. +function sameCacheValue(previous: unknown, next: unknown): boolean { + if (previous === next) return true; + if ( + previous === null || + next === null || + typeof previous !== "object" || + typeof next !== "object" + ) { + return false; + } + if (Array.isArray(previous) || Array.isArray(next)) { + if (!Array.isArray(previous) || !Array.isArray(next) || previous.length !== next.length) { + return false; + } + for (let index = 0; index < previous.length; index += 1) { + if (!sameCacheValue(previous[index], next[index])) return false; + } + return true; + } + if ( + Object.getPrototypeOf(previous) !== Object.prototype || + Object.getPrototypeOf(next) !== Object.prototype + ) { + return false; + } + const before = previous as Record; + const after = next as Record; + return ( + Object.keys(before).every((key) => + sameCacheValue(before[key], Object.hasOwn(after, key) ? after[key] : undefined), + ) && Object.keys(after).every((key) => Object.hasOwn(before, key) || after[key] === undefined) + ); +} + export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( function* (subscription: ServerConfigSubscriptionOptions) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; @@ -383,13 +418,22 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf ); const persistence = yield* Queue.sliding(1); const pendingPersistence = yield* Ref.make>(Option.none()); + let lastPersisted: ServerConfig | undefined; const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( config: ServerConfig, ) { // A cached stall would read as a fresh freeze on the next launch. - const { lastEventLoopStall: _stall, ...cacheable } = withoutEnvironmentThemes(config); - return yield* cache.saveServerConfig(environmentId, cacheable).pipe( + const { + environmentThemes: _themes, + usageLimitSources: _sources, + lastEventLoopStall: _stall, + ...cacheable + } = config; + if (lastPersisted !== undefined && sameCacheValue(lastPersisted, cacheable)) { + return true; + } + const saved = yield* cache.saveServerConfig(environmentId, cacheable).pipe( Effect.as(true), Effect.catch((error) => Effect.logWarning("Could not persist cached server configuration.").pipe( @@ -401,6 +445,8 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf ), ), ); + if (saved) lastPersisted = cacheable; + return saved; }); const persistPending = Effect.fn("EnvironmentServerConfigState.persistPending")(function* ( diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 0e76e321bf3a..56476f580b41 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -17,6 +17,7 @@ import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { AVAILABLE_CONNECTION_STATE, @@ -63,7 +64,156 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { }; } +const makePersistenceHarness = Effect.fn("test.makeShellPersistenceHarness")(function* ( + saveShell: Persistence.EnvironmentCacheStore["Service"]["saveShell"], +) { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const state = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, { + load: () => Effect.succeedNone, + }), + ); + const send = Effect.fn("test.sendShellItem")(function* ( + item: OrchestrationV2ShellStreamItem, + ready: (snapshot: OrchestrationV2ShellSnapshot) => boolean, + ) { + const previous = yield* SubscriptionRef.get(state); + yield* Queue.offer(events, item); + return yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => value !== previous), + Stream.map((value) => value.snapshot), + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.filter(ready), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + }); + yield* send({ kind: "snapshot", snapshot: LIVE_SHELL_SNAPSHOT }, () => true); + return { state, send }; +}); + describe("environment shell synchronization", () => { + it.effect("skips unchanged shell writes, persists resets and flushes changed rows", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makePersistenceHarness((_id, snapshot) => + Ref.update(saved, (values) => [...values, snapshot]), + ); + yield* TestClock.adjust("500 millis"); + yield* h.send( + { kind: "thread.updated", sequence: 10, location: "active", thread: v2ThreadShell }, + (snapshot) => snapshot.snapshotSequence === 10, + ); + yield* TestClock.adjust("10 seconds"); + expect((yield* Ref.get(saved)).map((snapshot) => snapshot.snapshotSequence)).toEqual([1]); + const live = Option.getOrThrow((yield* SubscriptionRef.get(h.state)).snapshot); + expect(live.snapshotSequence).toBe(10); + const enriched = yield* h.send( + { + kind: "snapshot", + snapshot: { ...live, projects: live.projects.map((project) => ({ ...project })) }, + resolvedRepositoryIdentityRoots: live.projects.map( + (project) => project.workspaceRoot, + ), + }, + () => true, + ); + expect(enriched.projects).toEqual(live.projects); + expect(enriched.snapshotSequence).toBe(10); + yield* TestClock.adjust("10 seconds"); + expect((yield* Ref.get(saved)).map((snapshot) => snapshot.snapshotSequence)).toEqual([1]); + yield* h.send( + { kind: "snapshot", snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 4 } }, + (snapshot) => snapshot.snapshotSequence === 4, + ); + yield* TestClock.adjust("10 seconds"); + yield* h.send( + { + kind: "snapshot", + snapshot: { + ...LIVE_SHELL_SNAPSHOT, + snapshotSequence: 4, + projects: [{ ...v2Project, title: "Changed at same sequence" }], + }, + }, + (snapshot) => snapshot.projects[0]?.title === "Changed at same sequence", + ); + }), + ); + expect( + (yield* Ref.get(saved)).map((snapshot) => [ + snapshot.snapshotSequence, + snapshot.projects[0]?.title, + ]), + ).toEqual([ + [1, v2Project.title], + [4, v2Project.title], + [4, "Changed at same sequence"], + ]); + }), + ); + + it.effect("retries a failed shell save after a cursor-only update", () => + Effect.gen(function* () { + const attempts = yield* Ref.make>([]); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makePersistenceHarness((_id, snapshot) => + Effect.gen(function* () { + const values = yield* Ref.updateAndGet(attempts, (values) => [ + ...values, + snapshot.snapshotSequence, + ]); + if (values.length === 1) + return yield* new Persistence.ConnectionPersistenceError({ + operation: "save-shell", + message: "test failure", + }); + }), + ); + yield* TestClock.adjust("500 millis"); + yield* h.send( + { kind: "thread.updated", sequence: 2, location: "active", thread: v2ThreadShell }, + (snapshot) => snapshot.snapshotSequence === 2, + ); + yield* TestClock.adjust("10 seconds"); + }), + ); + expect(yield* Ref.get(attempts)).toEqual([1, 2]); + }), + ); + it.effect("flushes the latest live snapshot without blocking connection state updates", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a01465d2dc7e..2c51e3a1a317 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -125,6 +125,19 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") if (Option.isNone(latest) || latest.value === lastPersisted) return; if (latest.value.threads.some((thread) => pendingLinkRows.has(thread))) return; const snapshot = latest.value; + if ( + lastPersisted !== undefined && + snapshot.snapshotSequence >= lastPersisted.snapshotSequence && + snapshot.schemaVersion === lastPersisted.schemaVersion && + snapshot.projects === lastPersisted.projects && + snapshot.threads === lastPersisted.threads && + snapshot.archivedThreads === lastPersisted.archivedThreads + ) { + // Keep the live cursor without rewriting the rows. Remember it here too so a + // later authoritative reset to a lower sequence still reaches the cache. + lastPersisted = snapshot; + return; + } const saved = yield* cache.saveShell(environmentId, snapshot).pipe( Effect.as(true), Effect.catch((error) => diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index eec7f6c949fc..2d9647e85fc1 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -92,27 +92,27 @@ export function mergeShellSnapshotProjects( if (isEnrichment) { const nextById = new Map(next.projects.map((project) => [project.id, project] as const)); - return { - ...previous, - projects: previous.projects.map((project) => { - const candidate = nextById.get(project.id); - if (candidate === undefined || candidate.workspaceRoot !== project.workspaceRoot) { + const projects = previous.projects.map((project) => { + const candidate = nextById.get(project.id); + if (candidate === undefined || candidate.workspaceRoot !== project.workspaceRoot) { + return project; + } + if (resolvedRootSet?.has(project.workspaceRoot) === true) { + // Refreshes re-resolve every root; an unchanged identity keeps the project's identity + // so every subscriber (each pull request row, the sidebar) skips re-rendering. + if (sameRepositoryIdentity(project.repositoryIdentity, candidate.repositoryIdentity)) { return project; } - if (resolvedRootSet?.has(project.workspaceRoot) === true) { - // Refreshes re-resolve every root; an unchanged identity keeps the project's identity - // so every subscriber (each pull request row, the sidebar) skips re-rendering. - if (sameRepositoryIdentity(project.repositoryIdentity, candidate.repositoryIdentity)) { - return project; - } - return { ...project, repositoryIdentity: candidate.repositoryIdentity }; - } - if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) { - return { ...project, repositoryIdentity: candidate.repositoryIdentity }; - } - return project; - }), - }; + return { ...project, repositoryIdentity: candidate.repositoryIdentity }; + } + if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) { + return { ...project, repositoryIdentity: candidate.repositoryIdentity }; + } + return project; + }); + return projects.every((project, index) => project === previous.projects[index]) + ? previous + : { ...previous, projects }; } const previousById = new Map(previous.projects.map((project) => [project.id, project] as const)); From 3e20b652e2dc84bedaaeb22089886ed45756fce6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:55:53 -0700 Subject: [PATCH 20/34] fix(mobile): remove perpetual reconnect indicator animation (#24273) --- .../connection/ConnectionStatusDot.tsx | 56 +++---------------- 1 file changed, 8 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index 3067296c166d..e2d6bc5e3b99 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -1,13 +1,4 @@ -import { useEffect } from "react"; import { View } from "react-native"; -import Animated, { - cancelAnimation, - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from "react-native-reanimated"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -50,49 +41,17 @@ function statusDotTone( } } -function usePulseAnimation(pulse: boolean) { - const pulseProgress = useSharedValue(0); - - useEffect(() => { - if (pulse) { - pulseProgress.value = withRepeat( - withTiming(1, { - duration: 1100, - easing: Easing.out(Easing.cubic), - }), - -1, - false, - ); - return; - } - - cancelAnimation(pulseProgress); - pulseProgress.value = withTiming(0, { - duration: 180, - easing: Easing.out(Easing.quad), - }); - }, [pulse, pulseProgress]); - - return pulseProgress; -} - export function ConnectionStatusDot(props: { readonly state: ConnectionStatusDotState; readonly pulse: boolean; readonly size?: number; }) { - const pulseProgress = usePulseAnimation(props.pulse); const { themeAppearance, themeVariables } = useAppearancePreferences(); const tone = statusDotTone(props.state, themeVariables, themeAppearance === "dark"); const dotSize = props.size ?? 10; const haloSize = dotSize + 4; const containerSize = haloSize + 4; - const haloStyle = useAnimatedStyle(() => ({ - opacity: props.pulse ? 0.14 + (1 - pulseProgress.value) * 0.3 : 0, - transform: [{ scale: 0.78 + pulseProgress.value * 1.16 }], - })); - return ( - + opacity: 0.3, + }} + /> + ) : null} Date: Sun, 25 Oct 2026 00:57:47 -0700 Subject: [PATCH 21/34] feat(mobile): close the keyboard from the Duo's side bar (#24696) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/native/V5StackHeader.ios.tsx | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx index 507beb3fe1af..7fc128f52ecb 100644 --- a/apps/mobile/src/native/V5StackHeader.ios.tsx +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -5,6 +5,7 @@ import type { } from "@react-navigation/native-stack"; import { isValidElement, useEffect, useRef } from "react"; import { StyleSheet } from "react-native"; +import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import { isDuoDevice } from "../lib/device-type"; import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; import { useDeviceType, useNativeLayoutMetrics } from "../native/native-layout-metrics"; @@ -153,6 +154,7 @@ export function V5StackHeader(props: { const theme = useMobileNavigationTheme(); const nativeMetrics = useNativeLayoutMetrics(); const deviceType = useDeviceType(); + const keyboardVisible = useKeyboardState((state) => state.isVisible); const searchRef = useRef(null); const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); @@ -231,6 +233,28 @@ export function V5StackHeader(props: { trailing.push({ type: "spacer", id: "compose-group", sizing: "fixed", width: 8 }); trailing.push(compose); } + // The vertical bar has no keyboard accessory row, so it carries its own dismiss. A regular-width + // primary column draws a horizontal bar beside the detail column's vertical one. The first + // trailing item sits at the bar's far end; the flexible space keeps it next to the keyboard. + if ( + keyboardVisible && + nativeMetrics && + nativeMetrics.verticalBarEdge !== "none" && + (props.column !== "primary" || nativeMetrics.horizontalSizeClass === "compact") + ) { + trailing.unshift( + { + type: "item", + id: "dismiss-keyboard", + title: "Dismiss keyboard", + icon: { type: "sfSymbol", name: "keyboard.chevron.compact.down" }, + onPress: () => void KeyboardController.dismiss(), + // The folded bar is short; keep this out of the overflow menu. + visibilityPriority: "high", + }, + { type: "spacer", id: "dismiss-keyboard-space", sizing: "flexible" }, + ); + } const left = options.headerLeft?.(itemProps); const right = options.headerRight?.(itemProps); if (!options.unstable_headerLeftItems && isValidElement(left)) From cfdfc2ab7c7afbc90ab36d9443d7806675e2b4f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 01:00:50 -0700 Subject: [PATCH 22/34] fix(mobile): dispatch native toolbar actions to current handlers (#24271) --- .../src/native/NativeHeaderToolbar.test.tsx | 163 ++++++++++++++---- .../mobile/src/native/NativeHeaderToolbar.tsx | 55 ++++-- 2 files changed, 163 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx index 2b3275de637d..b531e8f1a7a4 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx @@ -1,5 +1,5 @@ import type { NativeStackHeaderItem } from "@react-navigation/native-stack"; -import { act, createContext, type ReactElement } from "react"; +import { act, createContext, startTransition, Suspense, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vite-plus/test"; @@ -14,7 +14,6 @@ const harness = vi.hoisted(() => ({ appliedOptions: {} as HeaderOptions, renderedItems: [] as NativeStackHeaderItem[], setOptionsCalls: 0, - openedDocuments: [] as string[], })); // Native headers retain the actions materialized when navigation applies options. Like React @@ -51,26 +50,6 @@ vi.mock("@react-navigation/native", async () => { import { NativeHeaderToolbar } from "./NativeHeaderToolbar"; let root: Root | null = null; -type Placement = "left" | "right" | "bottom"; - -function DocumentToolbar(props: { readonly path: string; readonly placement: Placement }) { - return ( - - - harness.openedDocuments.push(props.path)}> - Open PDF - - - - ); -} - -function renderDocument(path: string, placement: Placement) { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - harness.params = { path: path.split("/") }; - root ??= createRoot(document.createElement("div")); - act(() => root!.render()); -} // Like React Navigation's NavigationStateContext, whose default value throws when // read. React restores a context's default outside render, so layout effects see it. @@ -109,33 +88,76 @@ function renderedCustomLabel() { return (item.element as ReactElement<{ "aria-label": string }>).props["aria-label"]; } -function openRenderedDocument() { - const menu = harness.renderedItems[0]; - if (menu?.type !== "menu") throw new Error("Expected the native file menu"); - const action = menu.menu.items[0]; - if (action?.type !== "action") throw new Error("Expected the native Open PDF action"); - action.onPress?.(); -} - afterEach(() => { act(() => root?.unmount()); root = null; harness.appliedOptions = {}; harness.renderedItems = []; harness.setOptionsCalls = 0; - harness.openedDocuments = []; vi.unstubAllGlobals(); }); it.each(["left", "right", "bottom"] as const)( - "updates the %s header action when switching between documents with the same name", + "dispatches retained %s actions to the latest committed handlers without registering again", (placement) => { - renderDocument("first/guide.pdf", placement); - openRenderedDocument(); - renderDocument("second/guide.pdf", placement); - openRenderedDocument(); - - expect(harness.openedDocuments).toEqual(["first/guide.pdf", "second/guide.pdf"]); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(document.createElement("div")); + const onPress = vi.fn(); + const render = (path: string) => + act(() => + root!.render( + + onPress("button", path)} /> + + onPress("menu", path)}> + Open + + + onPress("nested", path)}> + Open + + + + , + ), + ); + + render("first/guide.pdf"); + const [button, menu] = harness.renderedItems; + if (button?.type !== "button" || menu?.type !== "menu") { + throw new Error("Expected the native button and menu"); + } + const [action, submenu] = menu.menu.items; + if (action?.type !== "action" || submenu?.type !== "submenu") { + throw new Error("Expected the direct action and submenu"); + } + const nested = submenu.items[0]; + if (nested?.type !== "action") throw new Error("Expected the nested action"); + const press = () => { + button.onPress?.(); + action.onPress?.(); + nested.onPress?.(); + }; + press(); + expect(onPress.mock.calls).toEqual([ + ["button", "first/guide.pdf"], + ["menu", "first/guide.pdf"], + ["nested", "first/guide.pdf"], + ]); + onPress.mockClear(); + const registrations = harness.setOptionsCalls; + expect(registrations).toBeGreaterThan(0); + + render("second/guide.pdf"); + + expect(harness.setOptionsCalls).toBe(registrations); + expect(harness.renderedItems).toEqual([button, menu]); + press(); + expect(onPress.mock.calls).toEqual([ + ["button", "second/guide.pdf"], + ["menu", "second/guide.pdf"], + ["nested", "second/guide.pdf"], + ]); }, ); @@ -150,3 +172,68 @@ it("applies a custom header element rendered inside a navigation container", () renderConnecting("Reconnecting"); expect(renderedCustomLabel()).toBe("Reconnecting"); }); + +it("keeps committed handlers while a replacement render suspends", () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(document.createElement("div")); + const onPress = vi.fn(); + const pending = new Promise(() => {}); + function Suspend(): never { + throw pending; + } + const toolbar = (value: string, suspend = false) => ( + + + onPress(value)} /> + + {suspend && } + + ); + act(() => root!.render(toolbar("committed"))); + const button = harness.renderedItems[0]; + if (button?.type !== "button") throw new Error("Expected the native button"); + + act(() => startTransition(() => root!.render(toolbar("pending", true)))); + button.onPress?.(); + expect(onPress.mock.calls).toEqual([["committed"]]); + + act(() => root!.render(toolbar("replacement"))); + button.onPress?.(); + expect(onPress.mock.calls).toEqual([["committed"], ["replacement"]]); +}); + +it("isolates toolbar handlers and ignores retained actions after their toolbar unmounts", () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(document.createElement("div")); + const onPress = vi.fn(); + const toolbar = (id: string, value: string) => ( + + onPress(id, value)} /> + + ); + act(() => root!.render([toolbar("first", "initial")])); + const first = harness.renderedItems[0]; + if (first?.type !== "button") throw new Error("Expected the first native button"); + act(() => root!.render([toolbar("first", "initial"), toolbar("second", "initial")])); + const second = harness.renderedItems[0]; + if (second?.type !== "button") throw new Error("Expected the second native button"); + + act(() => root!.render([toolbar("first", "updated"), toolbar("second", "updated")])); + first.onPress?.(); + second.onPress?.(); + expect(onPress.mock.calls).toEqual([ + ["first", "updated"], + ["second", "updated"], + ]); + + onPress.mockClear(); + act(() => root!.render([toolbar("second", "remaining")])); + first.onPress?.(); + second.onPress?.(); + expect(onPress.mock.calls).toEqual([["second", "remaining"]]); + + onPress.mockClear(); + act(() => root!.render(null)); + second.onPress?.(); + expect(onPress).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx index 47a2ecb00086..88b72e2335fc 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx @@ -9,6 +9,7 @@ import { isValidElement, useLayoutEffect, useMemo, + useRef, use, type ReactElement, type ReactNode, @@ -60,6 +61,8 @@ function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { return undefined; } +type RegisterHandler = (handler: unknown) => () => void; + type ToolbarElementProps = Record & { readonly children?: ReactNode }; function elementTypeName(element: ReactElement): string | undefined { @@ -72,6 +75,7 @@ function elementTypeName(element: ReactElement): string | undefined { function convertMenuAction( element: ReactElement, + register: RegisterHandler, ): NativeStackHeaderItemMenu["menu"]["items"][number] | null { const typeName = elementTypeName(element); if (typeName === "NativeHeaderToolbarMenuAction") { @@ -82,10 +86,7 @@ function convertMenuAction( description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined, disabled: Boolean(element.props.disabled), icon: iconFromProp(element.props.icon), - onPress: - typeof element.props.onPress === "function" - ? (element.props.onPress as () => void) - : () => undefined, + onPress: register(element.props.onPress), state: element.props.isOn === true ? "on" : undefined, destructive: Boolean(element.props.destructive), discoverabilityLabel: @@ -104,30 +105,36 @@ function convertMenuAction( : labelFromChildren(element.props.children), icon: iconFromProp(element.props.icon), inline: Boolean(element.props.inline), - items: collectMenuItems(element.props.children), + items: collectMenuItems(element.props.children, register), }; } return null; } -function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] { +function collectMenuItems( + children: ReactNode, + register: RegisterHandler, +): NativeStackHeaderItemMenu["menu"]["items"] { const items: NativeStackHeaderItemMenu["menu"]["items"] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) { return; } - const item = convertMenuAction(child); + const item = convertMenuAction(child, register); if (item) { items.push(item); return; } - items.push(...collectMenuItems(child.props.children)); + items.push(...collectMenuItems(child.props.children, register)); }); return items; } -function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { +function convertToolbarChild( + child: ReactNode, + register: RegisterHandler, +): NativeToolbarItem | null { if (!isValidElement(child)) { return null; } @@ -154,10 +161,7 @@ function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior, disabled: Boolean(child.props.disabled), icon: iconFromProp(child.props.icon), - onPress: - typeof child.props.onPress === "function" - ? (child.props.onPress as () => void) - : () => undefined, + onPress: register(child.props.onPress), sharesBackground: !child.props.separateBackground, tintColor: child.props.tintColor as ColorValue | undefined, variant: @@ -182,7 +186,7 @@ function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { icon: iconFromProp(child.props.icon), menu: { title: typeof child.props.title === "string" ? child.props.title : undefined, - items: collectMenuItems(child.props.children), + items: collectMenuItems(child.props.children, register), }, sharesBackground: !child.props.separateBackground, tintColor: child.props.tintColor as ColorValue | undefined, @@ -205,10 +209,10 @@ function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { return null; } -function collectToolbarItems(children: ReactNode): NativeToolbarItem[] { +function collectToolbarItems(children: ReactNode, register: RegisterHandler): NativeToolbarItem[] { const items: NativeToolbarItem[] = []; Children.forEach(children, (child) => { - const item = convertToolbarChild(child); + const item = convertToolbarChild(child, register); if (item) { if (item.type === "spacing") { // Native defaults missing spacing-item indices to zero, moving spacers before earlier siblings. @@ -228,7 +232,24 @@ function NativeHeaderToolbarRoot(props: { const route = useRoute(); const tabActivity = use(NativeWorkspaceTabActivityContext); const isTabActive = tabActivity?.isActive; - const items = useMemo(() => collectToolbarItems(props.children), [props.children]); + const committedHandlers = useRef<(() => void)[]>([]); + const { items, handlers } = useMemo(() => { + const handlers: (() => void)[] = []; + const register: RegisterHandler = (handler) => { + const index = handlers.length; + handlers.push(typeof handler === "function" ? (handler as () => void) : () => undefined); + return () => committedHandlers.current[index]?.(); + }; + return { items: collectToolbarItems(props.children, register), handlers }; + }, [props.children, committedHandlers]); + // Native retains actions when unchanged presentation skips re-registration. + // Publish only committed handlers; abandoned renders must not replace them. + useLayoutEffect(() => { + committedHandlers.current = handlers; + return () => { + committedHandlers.current = []; + }; + }, [handlers, committedHandlers]); // UIKit's search placement item belongs to the toolbar, never a header group. const headerItems = useMemo( () => items.filter((item): item is NativeStackHeaderItem => item.type !== "searchBarPlacement"), From 2da177c2a6ca305ce89a53a776ed91494ffafd51 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:03:41 -0700 Subject: [PATCH 23/34] fix(server): PR watch wakes say the PR stays linked and watched (#24781) Co-authored-by: Claude Opus 5.5 --- apps/server/src/orchestration-v2/pullRequestWatch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 810a899e1778..1213948cc4d6 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -232,7 +232,7 @@ export function pullRequestWatchMessage(input: { "", exhausted ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` - : "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", + : "Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", ].join("\n"); const failed = changes.some( (change) => change.kind === "checks-failed" || change.kind === "conflicting", From 003b64dfa536e545079565d36ae0c2a5dba73b34 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:04:25 -0700 Subject: [PATCH 24/34] feat(server): thread tools return a ready t3-thread link (#24784) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 18 +++++++++--------- apps/server/src/mcp/OrchestratorMcpService.ts | 6 ++++++ .../src/mcp/toolkits/orchestrator/tools.ts | 4 ++-- .../src/mcp/toolkits/project/handlers.test.ts | 2 ++ .../src/mcp/toolkits/project/handlers.ts | 2 ++ apps/server/src/mcp/toolkits/project/tools.ts | 3 ++- .../server/src/mcp/toolkits/thread/handlers.ts | 9 ++++++++- apps/server/src/mcp/toolkits/thread/tools.ts | 1 + .../orchestrator-mcp-server.md | 3 ++- packages/contracts/src/orchestratorMcp.ts | 5 +++++ packages/shared/src/threadLinks.test.ts | 8 ++++++++ 11 files changed, 47 insertions(+), 14 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index cfb6408b79c0..e7dbfa7720dc 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -89,7 +89,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: childRunId, @@ -223,7 +223,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: RunId.make("run:mcp-restart-child"), @@ -316,7 +316,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], @@ -400,7 +400,7 @@ describe("OrchestratorMcpService", () => { }) as unknown as OrchestrationV2ThreadProjection; // A finished run whose binding to the follow-up was lost. const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: childState === "no runs" ? [] @@ -503,7 +503,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -583,7 +583,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -669,7 +669,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -764,7 +764,7 @@ describe("OrchestratorMcpService", () => { [ childThreadId, { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], contextTransfers: [], messages: [], @@ -992,7 +992,7 @@ describe("OrchestratorMcpService provider resolution", () => { }) as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f33956104400..d8704a88188b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -67,6 +67,7 @@ import { } from "@t3tools/contracts"; import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, @@ -855,6 +856,7 @@ function listItemFromShell( return { threadId: shell.id, title: shell.title, + link: formatThreadLink(shell.id, shell.title), createdBy: shell.createdBy, creationSource: shell.creationSource, status: shell.activityRunStatus ?? shell.status, @@ -887,6 +889,7 @@ function threadDetail( threadId: projection.thread.id, projectId: projection.thread.projectId, title: projection.thread.title, + link: formatThreadLink(projection.thread.id, projection.thread.title), createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, status: active?.status ?? latest?.status ?? "idle", @@ -1597,6 +1600,7 @@ const make = Effect.gen(function* () { const response = { taskId: task.id, childThreadId: task.childThreadId, + link: formatThreadLink(task.childThreadId, childControls.thread.title), childRunId: childRun?.id ?? null, childNodeId: task.id, status, @@ -2648,6 +2652,7 @@ const make = Effect.gen(function* () { ); return { threadId, + link: formatThreadLink(threadId, projection.thread.title), runId: run?.id ?? null, status: run?.status ?? "idle", title: projection.thread.title, @@ -2839,6 +2844,7 @@ const make = Effect.gen(function* () { ); return { threadId: input.threadId, + link: formatThreadLink(input.threadId, target.thread.title), messageId, runId: result.run.id, ...(result.run.delegatedTaskId === undefined ? {} : { taskId: result.run.delegatedTaskId }), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 0aa0b430b7c7..c193a0afb159 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -214,7 +214,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. Mention a thread with its `link`.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -228,7 +228,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. Mention a thread with its `link`.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 4c22393bf476..ccc4443d0b7c 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -81,6 +81,7 @@ const clientLaunchHarness = (input: { thread: { id: launch.threadId, projectId: launch.projectId, + title: launch.title, modelSelection: launch.modelSelection, }, runs: [], @@ -159,6 +160,7 @@ it.effect( expect(belowCeiling.result).toMatchObject({ projectId: belowCeiling.projectId, modelSelection: belowCeiling.modelSelection, + link: `[Fix](t3-thread://v1/${belowCeiling.launched[0]?.threadId})`, }); expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required"); expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 9522c8d28fec..aaf37468f8cd 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -11,6 +11,7 @@ import * as Repositories from "../../../sourceControl/SourceControlRepositorySer import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; @@ -197,6 +198,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, + link: formatThreadLink(thread.id, thread.title), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 2114565d8201..fbe73215113c 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -104,7 +104,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Mention a thread with its `link`. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -133,6 +133,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), success: Schema.Struct({ threadId: ThreadId, + link: Schema.optional(Schema.String), projectId: ProjectId, modelSelection: ModelSelection, runId: Schema.NullOr(RunId), diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 1a841542eac9..8a15d1f15656 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -105,9 +106,15 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { Effect.gen(function* () { const { caller } = yield* readCaller(); const threadSearch = yield* ThreadSearch.ThreadSearch; - return yield* threadSearch + const { matches } = yield* threadSearch .searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId }) .pipe(Effect.mapError(unavailable)); + return { + matches: matches.map((match) => ({ + ...match, + link: formatThreadLink(match.threadId, match.title), + })), + }; }), ), t3_thread_fork: writesThread((input) => diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index c23f032522eb..5e015368fd28 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -254,6 +254,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { threadId: OrchestrationThreadSearchMatch.fields.threadId, projectId: OrchestrationThreadSearchMatch.fields.projectId, title: Schema.String, + link: Schema.optional(Schema.String), source: Schema.Literals(["title", "user", "assistant"]), snippet: Schema.optional(OrchestrationThreadSearchMatch.fields.snippet), messageCreatedAt: Schema.optional(OrchestrationThreadSearchMatch.fields.messageCreatedAt), diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 4a53d19b0087..dc4af856b8c3 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -391,7 +391,8 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. -Agents mention another thread as `[title](t3-thread://v1/)`. The +Agents mention another thread as `[title](t3-thread://v1/)`. Thread +results carry this ready-made as `link`, so agents never format the id. The link carries only the id. Clients first resolve it in the message's environment, then use a unique match in another enabled environment. Ambiguous matches stay unresolved. Web and desktop show the resolved thread’s current title. Mobile diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 55fe175237f4..f6e80bce9e58 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -190,6 +190,7 @@ export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTas export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ taskId: NodeId, childThreadId: ThreadId, + link: Schema.optional(Schema.String), childRunId: Schema.NullOr(RunId), childNodeId: NodeId, status: OrchestratorMcpDelegatedTaskStatus, @@ -273,6 +274,7 @@ export type OrchestratorMcpCreatedThreadStatus = typeof OrchestratorMcpCreatedTh export const OrchestratorMcpCreatedThread = Schema.Struct({ threadId: ThreadId, + link: Schema.optional(Schema.String), runId: Schema.NullOr(RunId), status: OrchestratorMcpCreatedThreadStatus, title: Schema.String, @@ -327,6 +329,7 @@ export type OrchestratorMcpThreadPullRequest = typeof OrchestratorMcpThreadPullR export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, title: Schema.String, + link: Schema.optional(Schema.String), createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, status: OrchestratorMcpThreadStatus, @@ -394,6 +397,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + link: Schema.optional(Schema.String), createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, status: OrchestratorMcpThreadStatus, @@ -475,6 +479,7 @@ export type OrchestratorMcpThreadSendInput = typeof OrchestratorMcpThreadSendInp export const OrchestratorMcpThreadSendResult = Schema.Struct({ threadId: ThreadId, + link: Schema.optional(Schema.String), messageId: MessageId, runId: RunId, taskId: Schema.optional(NodeId), diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index ef27422dedf8..a6c8eebc7e65 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -98,6 +98,14 @@ describe("thread links", () => { ); }); + it("writes the thread id verbatim so the link opens that exact thread", () => { + const link = formatThreadLink("thread:delegated-task:mcp%3A1", "Fix"); + expect(link).toBe("[Fix](t3-thread://v1/thread:delegated-task:mcp%3A1)"); + expect(parseThreadLinkHref(link.slice(link.indexOf("](") + 2, -1))).toBe( + "thread:delegated-task:mcp%3A1", + ); + }); + it("formats a label that would otherwise break the Markdown link", () => { expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)"); expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)"); From a96cf0818eff4a113c3ff52ae702683e51a5803b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:07:17 -0700 Subject: [PATCH 25/34] fix(server): running task results tell the agent to end its turn (#24782) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 109 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 33 +++++- packages/contracts/src/orchestratorMcp.ts | 7 ++ 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index e7dbfa7720dc..6ce02cbb32e2 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, MessageId, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, type OrchestratorMcpCapabilitiesResult, OrchestratorMcpTarget, type OrchestrationV2ThreadShell, @@ -292,6 +293,114 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect("tells the parent to end its turn only while its task still runs", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-next-parent"); + const childThreadId = ThreadId.make("thread:mcp-next-child"); + const taskId = NodeId.make("node:mcp-next-task"); + let finished = false; + let pendingRequest = false; + const parentProjection = () => + ({ + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: finished ? "completed" : "running", + result: finished ? "done" : null, + completionDelivery: { state: "pending" }, + }, + ], + }) as unknown as OrchestrationV2ThreadProjection; + const childRun = () => ({ + id: RunId.make("run:mcp-next-child"), + ordinal: 1, + status: finished ? ("completed" as const) : ("running" as const), + delegatedTaskId: taskId, + }); + const childProjection = () => + ({ + thread: { id: childThreadId, title: "Child task" }, + runs: [childRun()], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: pendingRequest ? [{ status: "pending" }] : [], + nodes: [], + turnItems: [], + }) as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection() : childProjection()), + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + getProjectThreadRecords: () => Effect.succeed(childProjection()), + waitForThread: () => + Effect.succeed({ + threadId: childThreadId, + run: childRun() as unknown as OrchestrationV2ThreadProjection["runs"][number], + timedOut: !finished, + }), + dispatch: () => Effect.succeed({} as never), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-next"), + requestNamespace: "provider-session:mcp-next", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-next", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const running = yield* service.taskStatus(scope, taskId); + assert.equal(running.status, "running"); + assert.equal(running.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + const waiting = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(waiting.timedOut); + assert.equal(waiting.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + + // A child waiting on a question or approval needs an answer, not a wake. + pendingRequest = true; + const asking = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(asking.timedOut); + assert.notProperty(asking, "next"); + pendingRequest = false; + + finished = true; + const completed = yield* service.taskStatus(scope, taskId); + assert.equal(completed.status, "completed"); + assert.notProperty(completed, "next"); + const waited = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.equal(waited.status, "completed"); + assert.notProperty(waited, "next"); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("does not dispose delivery when a nonterminal task has no active child run", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index d8704a88188b..0df296db5bc1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -5,6 +5,7 @@ import { MessageId, type ModelSelection, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, TurnItemId, type OrchestrationV2Run, type OrchestrationV2Subagent, @@ -1597,7 +1598,7 @@ const make = Effect.gen(function* () { instanceId: task.providerInstanceId, model: task.model, }; - const response = { + const result = { taskId: task.id, childThreadId: task.childThreadId, link: formatThreadLink(task.childThreadId, childControls.thread.title), @@ -1633,6 +1634,10 @@ const make = Effect.gen(function* () { : pendingTaskApproval(childControls, task.id, childRun), waitTimedOut, } satisfies OrchestratorMcpDelegateTaskResult; + const response: OrchestratorMcpDelegateTaskResult = + endsTaskWait(result) || task.completionDelivery?.state === "disposed" + ? result + : { ...result, next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT }; if ( acknowledgeTerminal && isTerminalTaskStatus(status) && @@ -2853,7 +2858,7 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadSendResult; }), waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { - const { target } = yield* loadScopedThread(scope, input.threadId); + const { parent, target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ projectId: target.thread.projectId, @@ -2865,11 +2870,33 @@ const make = Effect.gen(function* () { ), }) .pipe(Effect.mapError(threadManagementFailure)); + const status = result.run === null ? "idle" : runOutcome(result.run); + const taskId = result.run?.delegatedTaskId; + const ownedTask = + result.timedOut && + status !== "waiting" && + parent?.subagents.some( + (task) => + task.id === taskId && + task.origin === "app_owned" && + task.threadId === parent.thread.id && + task.childThreadId === input.threadId && + task.completionDelivery?.state !== "disposed", + ) === true; + // A child waiting on a question or approval needs an answer, not a wake. + const childRequests = ownedTask + ? (yield* threadManagement + .getThreadRecords(input.threadId, ["runtimeRequests"]) + .pipe(Effect.mapError(threadManagementFailure))).runtimeRequests + : []; + const wakesCaller = + ownedTask && !childRequests.some((request) => request.status === "pending"); return { threadId: input.threadId, runId: result.run?.id ?? null, - status: result.run === null ? "idle" : runOutcome(result.run), + status, timedOut: result.timedOut, + ...(wakesCaller ? { next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } : {}), } satisfies OrchestratorMcpThreadWaitResult; }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index f6e80bce9e58..49924b068678 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -187,6 +187,11 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ }); export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTaskInput.Type; +// Set only on results for still-running tasks whose completion will wake the parent. +export const ORCHESTRATOR_MCP_RUNNING_TASK_NEXT = + "Completion wakes this thread. End your turn unless you need this result now."; +const OrchestratorMcpRunningTaskNext = Schema.Literal(ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ taskId: NodeId, childThreadId: ThreadId, @@ -224,6 +229,7 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ description: "True only on that mode=wait call when timeoutMs elapsed. The timeout does not cancel the child. Later task_status reads return false and use status for liveness.", }), + next: Schema.optional(OrchestratorMcpRunningTaskNext), }); export type OrchestratorMcpDelegateTaskResult = typeof OrchestratorMcpDelegateTaskResult.Type; @@ -500,6 +506,7 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ runId: Schema.NullOr(RunId), status: OrchestratorMcpThreadStatus, timedOut: Schema.Boolean, + next: Schema.optional(OrchestratorMcpRunningTaskNext), }); export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type; From a8603afcf16623726374bdd44dcdc78a8049622e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:55:58 -0700 Subject: [PATCH 26/34] fix(server): mobile push no longer says "completed" between runs (#24769) Mobile push said "Thread completed" when a run ended and its continuation started a moment later, then again when the work really finished. Every completion now waits the relay's existing 5s confirm window and is dropped if the thread is live again. A thread that auto-settles during the hold still alerts its completion, then clears its card on a new window. Failed runs publish at once. Follow-up to #24760. Co-authored-by: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 111 ++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 41 ++++++- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b5f400dc3432..3ed240d754cc 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -392,6 +392,8 @@ describe("AgentAwarenessRelay", () => { const terminal = shell({ status }); yield* Ref.set(currentShell, terminal); yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.equal(publications[1]?.state?.phase, status); const now = yield* DateTime.now; @@ -419,6 +421,113 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped), ); + it.effect("holds a completion through a continuation gap, then publishes it once", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell()); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running"], + ); + + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running", "completed"], + ); + }), + ); + + it.effect("still publishes a completion when the thread settles during the hold", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + }), + ); + + it.effect("drops a held completion when a queued continuation hides the thread", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "queued" })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.notInclude( + publications.map((publication) => publication.state?.phase ?? null), + "completed", + ); + }), + ); + + it.effect("retries a failed completion even after the thread settles", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay({ + respond: (attempt) => + attempt === 2 + ? new Response("unavailable", { status: 503 }) + : Response.json({ ok: true, deliveries: [] }), + }); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* TestClock.adjust("1 second"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state ?? null, null); + }), + ); + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); @@ -595,6 +704,8 @@ describe("AgentAwarenessRelay", () => { yield* deliver( itemEvent("event:held-item:end", item("item:tool", { ...background, status: "completed" })), ); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase), ["running", "completed"], diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 93f3a73b2192..e59065400b2d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -411,6 +411,8 @@ export const make = Effect.gen(function* () { // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); + // Threads whose confirmation holds a completion, so a thread that settles during the hold still alerts. + const heldCompletions = new Set(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -429,6 +431,7 @@ export const make = Effect.gen(function* () { publishedRelayConfig = undefined; cachedRelayClient = undefined; publishConfirmDeadlines.clear(); + heldCompletions.clear(); yield* Ref.set(publishedStateByThreadRef, new Map()); }); @@ -542,6 +545,7 @@ export const make = Effect.gen(function* () { if (publishedStateByThread.get(threadId) === publishIdentity) { // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, @@ -551,12 +555,14 @@ export const make = Effect.gen(function* () { } // Confirm tombstones over live state to avoid removing active cards. - // Confirm first-state completion because sessions briefly project completed before their first turn. + // Confirm completion: a run can end moments before its continuation starts, and the relay + // alerts on every move into completed. const requiresConfirmation = (snapshot.state === null && publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); + snapshot.state?.phase === "completed"; if (requiresConfirmation) { + if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -581,8 +587,38 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); + // Settled during the hold, not resumed: alert the current completion, then clear the card + // on a new window. Only settlement may hide it; queued or silent runs also project to null. + const unsettled = + snapshot.state === null && + heldCompletions.has(threadId) && + Option.isSome(thread) && + thread.value.settledOverride === "settled" + ? resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some({ ...thread.value, settledOverride: null }), + project, + }) + : null; + if (unsettled?.state?.phase === "completed") { + const completion = unsettled.state; + yield* publishState({ + projectId: unsettled.projectId, + state: completion, + reason: unsettled.reason, + }).pipe(withRelayClientTracing); + heldCompletions.delete(threadId); + publishConfirmDeadlines.set(threadId, nowMs + 5_000); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => + new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(completion)), + ); + yield* schedulePublishConfirm(threadId); + return; + } } else { publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); } if (snapshot.reason === "thread-not-found") { @@ -604,6 +640,7 @@ export const make = Effect.gen(function* () { reason: snapshot.reason, }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); if (snapshot.state === null) { From e641761e2aabd7488cc9049087d2ef6e8e24ddfc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:09:18 -0700 Subject: [PATCH 27/34] fix(server): delegated children no longer see pull request watch tools (#24808) Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 87 +++++++++++++++++++ apps/server/src/mcp/McpInvocationContext.ts | 13 +++ apps/server/src/mcp/McpSessionRegistry.ts | 2 + .../src/mcp/toolkits/pullRequests/tools.ts | 9 +- .../ProviderSessionManager.test.ts | 53 +++++++++++ .../ProviderSessionManager.ts | 9 +- 6 files changed, 169 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 286dc409f743..27e93f2ecefd 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -817,3 +817,90 @@ it.effect("admits provider and OAuth client credentials and points only clients }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("lists pull request watch tools only to threads that are not delegated children", () => + Effect.scoped( + Effect.gen(function* () { + const scopeFor = ( + token: string, + subagent: boolean, + ): McpInvocationContext.McpThreadInvocationScope => ({ + environmentId, + requestNamespace: token, + thread: { + threadId: ThreadId.make(`thread-${token}`), + providerSessionId: token, + providerInstanceId: ProviderInstanceId.make("codex"), + subagent, + }, + client: undefined, + capabilities: new Set(["pull-requests"]), + issuedAt: 1, + }); + const scopes = new Map([ + ["parentToken", scopeFor("parentToken", false)], + ["childToken", scopeFor("childToken", true)], + ]); + const serverLayer = McpHttpServer.layerPullRequestsToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (token) => Effect.succeed(scopes.get(token)), + }), + ), + Layer.provide( + Layer.mergeAll( + McpToolAccessTestkit.liveThreadsLayer, + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), + Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), + NodeServices.layer, + ), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const listedTools = (token: string) => + Effect.gen(function* () { + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + const listed = yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, + "application/json", + ), + }); + const body = (yield* listed.json) as { + readonly result: { readonly tools: ReadonlyArray<{ readonly name: string }> }; + }; + return body.result.tools.map((tool) => tool.name); + }); + + const parentTools = yield* listedTools("parentToken"); + const childTools = yield* listedTools("childToken"); + expect(parentTools).toEqual( + expect.arrayContaining(["link_pull_request", "watch_pull_request", "unwatch_pull_request"]), + ); + expect(childTools).toContain("link_pull_request"); + expect(childTools).not.toContain("watch_pull_request"); + expect(childTools).not.toContain("unwatch_pull_request"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index c479b03df73d..cc45af6bae4d 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; const ALL_MCP_CAPABILITIES = [ "preview", @@ -24,6 +25,8 @@ export interface McpThreadCaller { readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; + /** A delegated child task; its parent owns the work it hands back. */ + readonly subagent?: boolean; } export interface McpClientCaller { @@ -51,6 +54,16 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} +/** + * `McpSchema.EnabledWhen` for tools a delegated child must not see or call. + * EnabledWhen only receives the MCP client profile, so read the T3 caller from the request fiber. + */ +export const hiddenFromSubagents = (): boolean => { + const fiber = Fiber.getCurrent(); + const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext); + return scope?.thread?.subagent !== true; +}; + export type McpCapabilityError = C extends "preview" ? PreviewAutomationUnavailableError : McpCapabilityUnavailableError; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 2fc9879d7a34..ce424d7b662c 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -18,6 +18,7 @@ export interface McpCredentialRequest { /** False omits the preview capability wherever the token is honored. Defaults to full access. */ readonly browserToolsAvailable?: boolean; readonly capabilities?: ReadonlySet; + readonly subagent?: boolean; } export interface McpIssuedCredential { @@ -105,6 +106,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + subagent: request.subagent === true, }, client: undefined, capabilities: new Set([ diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 63c0efa1684a..cbc369deab05 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -10,6 +10,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import * as McpSchema from "effect/ai/McpSchema"; import * as Tool from "effect/ai/Tool"; import * as Toolkit from "effect/ai/Toolkit"; @@ -326,7 +327,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.", + "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.", parameters: PullRequestTargetInput, success: WatchPullRequestResult, failure: PullRequestToolError, @@ -336,7 +337,8 @@ const WatchPullRequestTool = Tool.make("watch_pull_request", { .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true) - .annotate(Tool.OpenWorld, false); + .annotate(Tool.OpenWorld, false) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents); const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { description: @@ -350,7 +352,8 @@ const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true) - .annotate(Tool.OpenWorld, false); + .annotate(Tool.OpenWorld, false) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents); const GuideTarget = Schema.Struct({ url: PullRequestTargetInput.fields.url, diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index a64997a8dd7b..010f11742933 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1560,6 +1560,59 @@ it.effect( }), ); +it.effect.each([ + { relationshipToParent: null, subagent: false }, + { relationshipToParent: "subagent", subagent: true }, +] as const)( + "ProviderSessionManagerV2 marks MCP credentials of delegated children ($relationshipToParent)", + ({ relationshipToParent, subagent }) => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-mcp-lineage"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const created = yield* makeThreadCreatedEvent({ idAllocator, threadId, now }); + const parentThreadId = ThreadId.make("thread-provider-session-manager-mcp-parent"); + yield* eventSink.write({ + events: [ + { + ...created, + payload: { + ...created.payload, + lineage: + relationshipToParent === null + ? created.payload.lineage + : { parentThreadId, relationshipToParent, rootThreadId: parentThreadId }, + }, + }, + ], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + const token = (yield* Ref.get(mcpConfigs))[0]?.authorizationHeader.replace( + /^Bearer\s+/, + "", + ); + assert.isDefined(token); + assert.equal((yield* registry.resolve(token!))?.thread.subagent, subagent); + yield* manager.close(providerSessionId); + }); + + yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1_000, mcpConfigs }))); + }), +); + it.effect( "ProviderSessionManagerV2 withholds the preview capability when agent browser access is off", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index a9daa1995bc5..00b42d510bd9 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -430,6 +430,10 @@ export const layerWithOptions = ( >(["orchestration", "worktree", "pull-requests"]); if (browserToolsAvailable) capabilities.add("preview"); if (deviceToolsAvailable) capabilities.add("device"); + const subagent = yield* projectionStore.getThread(threadId).pipe( + Effect.map((thread) => thread.lineage.relationshipToParent === "subagent"), + Effect.orElseSucceed(() => false), + ); const existing = yield* mcpSessions.read(threadId); if (existing !== undefined) { // Reserve before async validation so release cannot revoke between validation and reservation. @@ -450,7 +454,9 @@ export const layerWithOptions = ( resolved.thread.threadId === threadId && resolved.thread.providerInstanceId === providerInstanceId && resolved.capabilities.has("preview") === browserToolsAvailable && - resolved.capabilities.has("device") === deviceToolsAvailable + resolved.capabilities.has("device") === deviceToolsAvailable && + // A credential issued while the lineage read failed is replaced once it works. + (resolved.thread.subagent === true) === subagent ) { return { mcpCredentialId: existing.providerSessionId, issued: false }; } @@ -462,6 +468,7 @@ export const layerWithOptions = ( providerInstanceId, browserToolsAvailable, capabilities, + subagent, }); yield* mcpSessions.set(credential.config); reserveMcpCredential(threadId, credential.config.providerSessionId); From 5398be0d44ef06fb1646d8ad54b81b5613a03193 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:11:12 -0700 Subject: [PATCH 28/34] fix(mobile): search stays open after going back from a thread (#24748) Co-authored-by: Claude Opus 5.5 --- .../features/workspace/WorkspaceToolbar.ios.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index 821c4c70c34b..0ac48ac5e233 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -101,20 +101,14 @@ export function WorkspaceToolbar(props: { tab.searchMode !== "resting" ) { searchRef.current?.setText(tab.query); - if (duoSearch) { + // Integrated search collapses to its button when its screen reappears, + // which would hide the query that still filters the list. + if (NATIVE_LIQUID_GLASS_SUPPORTED) { searchRef.current?.setActive(true); if (tab.searchMode === "active") searchRef.current?.focus(); } } - }, [ - searchHostVisible, - duoSearch, - isTabActive, - selectedTab, - tab.searchMode, - tab.query, - searchVisible, - ]); + }, [searchHostVisible, isTabActive, selectedTab, tab.searchMode, tab.query, searchVisible]); useFocusEffect( useCallback(() => { const frame = requestAnimationFrame(restoreSearch); From e0b194021b151cd28e718f4cecd10095134dee45 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:28:07 -0700 Subject: [PATCH 29/34] feat(server): request_secret can save a CLI token to a private file (#24809) Co-authored-by: Claude Opus 5.5 --- .../features/threads/SecretRequestCard.tsx | 4 +- apps/server/src/mcp/OrchestratorMcpService.ts | 7 ++ .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/orchestration-v2/Orchestrator.ts | 1 + .../server/src/secrets/SecretRequests.test.ts | 119 ++++++++++++++++++ apps/server/src/secrets/SecretRequests.ts | 101 ++++++++++++++- .../src/components/chat/SecretRequestCard.tsx | 4 +- .../client-runtime/src/secretRequest.test.ts | 13 +- packages/client-runtime/src/secretRequest.ts | 6 +- packages/contracts/src/orchestrationV2.ts | 5 + packages/contracts/src/orchestratorMcp.ts | 11 ++ packages/contracts/src/secretRequest.ts | 2 + .../src/server/orchestrationInstructions.ts | 2 +- 13 files changed, 264 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/SecretRequestCard.tsx b/apps/mobile/src/features/threads/SecretRequestCard.tsx index 02d773c13f12..0112257fec71 100644 --- a/apps/mobile/src/features/threads/SecretRequestCard.tsx +++ b/apps/mobile/src/features/threads/SecretRequestCard.tsx @@ -1,9 +1,9 @@ import { SECRET_REQUEST_DEFAULT_PLACEHOLDER, - SECRET_REQUEST_PRIVACY_NOTE, secretRequestAnswerInput, secretRequestDisplay, secretRequestFailureMessage, + secretRequestPrivacyNote, type SecretRequestItem, } from "@t3tools/client-runtime/secret-request"; import { @@ -144,7 +144,7 @@ function PendingSecretRequestForm(props: { type="monochrome" /> - {SECRET_REQUEST_PRIVACY_NOTE} + {secretRequestPrivacyNote(item)}