From a893900b6d04f43cda7e0b51a020ed8e9c8173a6 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:37:48 -0600 Subject: [PATCH 01/25] fix: reconcile November 5 account lock and model labels Reconcile the accepted local runtime at 8819b23f461bcbb3a8089dd3bb26b37ab30d6e92. Preserve full model labels and link the Codex shared session-index lock before account startup. The existing lock regression failed on the canonical baseline and all nine focused cases pass with the fix. Integrated visual acceptance is reserved for checkpoint-B qualification. --- .../provider/Drivers/CodexHomeLayout.test.ts | 3 + .../src/provider/Drivers/CodexHomeLayout.ts | 3 +- apps/web/src/components/chat/ModelListRow.tsx | 84 ++++++++----------- .../components/chat/ModelPickerContent.tsx | 6 +- 4 files changed, 44 insertions(+), 52 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2bbcea20faa91..778b8c97ae0a2 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -157,6 +157,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect( + yield* fileSystem.readLink(path.join(shadowHome, "session_index.jsonl.lock")), + ).toBe(path.join(sharedHome, "session_index.jsonl.lock")); expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index f6d2ba9d44cf5..ceddde9072a40 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -400,7 +400,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" }), }), ); - const entries = new Set(KNOWN_SHARED_DIRECTORIES); + // Link the index lock before Codex creates it so both homes serialize shared index writes. + const entries = new Set([...KNOWN_SHARED_DIRECTORIES, "session_index.jsonl.lock"]); for (const entryName of sharedEntryNames) { if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) { entries.add(entryName); diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 2a33ea7676249..3c89ad59c08bf 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -41,6 +41,10 @@ export const ModelListRow = memo(function ModelListRow(props: { subProvider: props.subProvider, }; const modelName = getDisplayModelName(model); + const providerLabel = + props.subProvider && props.subProvider !== props.providerDisplayName + ? `${props.providerDisplayName} · ${props.subProvider}` + : props.providerDisplayName; const row = ( - {/* One row shape in every tab: the logo names the provider, so Favorites needs no second line. */} -
- - - {modelName} - {/* The logo is decorative, so name the provider for screen readers. */} - , {props.providerDisplayName} -
- } - /> - - {modelName} - {props.providerDisplayName} - - - {(props.showProvider ?? props.showInstanceBadge) && ( -
- +
+ + + {modelName} + , {props.providerDisplayName} +
+ } /> - - {props.providerDisplayName} + + {modelName} + {props.providerDisplayName} + + + {props.showNewBadge ? ( + + New + ) : null} + {props.unavailable ? ( + + Unavailable + + ) : null} +
+ {(props.showProvider ?? props.showInstanceBadge) || props.subProvider ? ( +
+ {providerLabel}
- )} - {props.subProvider ? ( - - {props.subProvider} - - ) : null} - {props.showNewBadge ? ( - - New - - ) : null} - {props.unavailable ? ( - - Unavailable - ) : null} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8148887caee30..8a9807cd715a3 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -299,7 +299,7 @@ function ModelPickerList({ return (
0 ? "flex-1" : "h-0", )} > @@ -840,7 +840,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { "relative flex flex-row overflow-hidden", props.docked ? "min-w-0 w-full sm:flex-1 max-sm:h-56 max-sm:min-h-0!" - : "w-screen max-w-90 max-h-86.5", + : "w-[min(22.5rem,calc(100vw-1rem))] max-h-86.5", )} style={ isSearching @@ -913,7 +913,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { >
From dbaa9e921fc7b79a4dee87411fdd046936356ea0 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:54:16 -0600 Subject: [PATCH 02/25] feat(settings): resolve source control and usage credentials by reference Integrate upstream 84537c4dd9, bff68fb6b6 and a10490937d as one atomic group. Preserve the fork credential collision guards and private routing while enabling references, mask references for older clients, and advertise the new field support separately from existing provider references. Include console-billing reference recognition. Focused backend and compatibility suites passed 173 cases, seven affected package typechecks passed, and before-fix failures reproduced per-host resolution, usage-key decoding and opaque-reference handling. Full checkpoint-B qualification follows the approved implementation tranche. --- .../src/provider/ProviderBilling.test.ts | 12 +- .../src/provider/ProviderInstanceRegistry.ts | 20 +- .../server/src/secrets/settingsSecretValue.ts | 10 +- apps/server/src/serverSettings.test.ts | 105 ++++++++-- apps/server/src/serverSettings.ts | 196 ++++++------------ apps/server/src/settingsSecrets.ts | 102 ++++++--- .../sourceControlHostSecrets.test.ts | 46 +++- .../sourceControl/sourceControlHostSecrets.ts | 25 ++- .../src/usage/UsageLimitSources.test.ts | 72 ++++++- apps/server/src/usage/UsageLimitSources.ts | 44 +++- apps/server/src/usage/cliproxyApi.ts | 46 ++-- apps/server/src/ws.test.ts | 8 +- apps/server/src/ws.ts | 36 +++- .../settings/AddUsageLimitSourceDialog.tsx | 25 ++- .../settings/ProviderInstanceCard.tsx | 5 +- .../SecretSourceInput.browser.test.tsx | 23 ++ .../components/settings/SecretSourceInput.tsx | 16 +- .../SourceControlAppSignInSettings.tsx | 6 +- .../settings/SourceControlTokenSettings.tsx | 48 +++-- .../src/connection/compatibility.test.ts | 1 + .../src/connection/compatibility.ts | 3 + .../src/connection/resolver.test.ts | 2 +- packages/contracts/src/environment.ts | 5 + packages/contracts/src/server.ts | 2 + packages/contracts/src/settings.ts | 8 +- packages/contracts/src/settingsSecretValue.ts | 70 +++++-- packages/shared/src/serverSettings.test.ts | 18 ++ packages/shared/src/serverSettings.ts | 34 ++- .../src/shared/definition.ts | 3 +- .../src/server/GitHubCredentials.ts | 8 +- .../src/server/GitHubSourceControlProvider.ts | 7 +- .../src/server/gitHubDiscovery.test.ts | 82 +++++++- 32 files changed, 790 insertions(+), 298 deletions(-) diff --git a/apps/server/src/provider/ProviderBilling.test.ts b/apps/server/src/provider/ProviderBilling.test.ts index 99ae67832ed6a..d7a3d2d812121 100644 --- a/apps/server/src/provider/ProviderBilling.test.ts +++ b/apps/server/src/provider/ProviderBilling.test.ts @@ -67,7 +67,11 @@ it.effect( }); assert.notInclude(JSON.stringify(result), "admin_synthetic_key"); assert.notInclude( - JSON.stringify(ServerSettings.redactServerSettingsForClient(yield* settings.getSettings)), + JSON.stringify( + ServerSettings.redactServerSettingsForClient(yield* settings.getSettings, { + secretReferences: true, + }), + ), "admin_synthetic_key", ); }).pipe(Effect.provide(fixture("admin_synthetic_key"))), @@ -168,7 +172,11 @@ it.effect( consoleBilling: encodeClaudeConsoleBillingConnection(connection), }); assert.notInclude( - JSON.stringify(ServerSettings.redactServerSettingsForClient(yield* settings.getSettings)), + JSON.stringify( + ServerSettings.redactServerSettingsForClient(yield* settings.getSettings, { + secretReferences: true, + }), + ), connection.organizationId, ); }).pipe(Effect.provide(claudeFixture())), diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index a944f751a1d34..99b80a79b6209 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -9,6 +9,8 @@ import { type SecretReference, secretReferencesInSettings, type ServerProvider, + type SettingsSecretSlot, + withSettingsSecrets, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -45,7 +47,7 @@ export class ProviderInstanceRegistry extends Context.Service< interface InstanceSecrets { readonly environment: ResolvedProviderInstanceEnvironment; - readonly config: Readonly>; + readonly config: ReadonlyArray; } interface LiveEntry { @@ -110,12 +112,12 @@ const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecr } return [{ ...variable, value: secret, sensitive: true }]; }); - const config: Record = {}; - for (const { field } of configFields) { + const config = configFields.flatMap(({ field, key }) => { const secret = values[index++]; - if (secret === undefined) unreadable.push(field); - else config[field] = secret; - } + if (secret !== undefined) return [{ field, key, value: secret }]; + unreadable.push(field); + return []; + }); const secrets: InstanceSecrets = { environment, config }; return { secrets, unreadable }; }); @@ -208,7 +210,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* reference)); - const resolved = Object.fromEntries( - pending.map(({ field }, index) => [field, values[index] ?? ""] as const), + return withSettingsSecrets( + settings, + pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })), ); - return { ...settings, ...resolved }; }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index c31893fea1253..9f2fb122866c8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -32,6 +32,7 @@ import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { SECRET_REDACTED } from "./settingsSecrets.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -100,7 +101,7 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi it.layer(NodeServices.layer)("server settings", (it) => { it.effect.each(["github.com", " GitHub.COM "])( - "preserves future credential references and private routing through older settings saves (%s)", + "loads credential references and private routing without materializing stale secrets (%s)", (host) => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; @@ -109,8 +110,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { const store = yield* ServerSecretStore.ServerSecretStore; const sourceId = UsageLimitSourceId.make("future-hub"); const instanceId = ProviderInstanceId.make("private-local"); - const reference = { provider: "personal", reference: "op://Private/GitHub/token" }; - const usageReference = { provider: "personal", reference: "op://Private/Hub/key" }; + const reference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/GitHub/token", + }; + const usageReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Hub/key", + }; const providerInstances = { [instanceId]: { driver: ProviderDriverKind.make("codex"), @@ -157,8 +164,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.equal(loaded.modelPrivacy, "private"); assert.deepEqual(loaded.privateModelRoute, privateModelRoute); assert.deepEqual(loaded.providerInstances[instanceId], providerInstances[instanceId]); - assert.deepEqual(loaded.sourceControlHosts.github?.tokens, { "github.com": "" }); - assert.equal(loaded.usageLimitSources[sourceId]?.managementKey, ""); + assert.deepEqual(loaded.sourceControlHosts.github?.tokens, { "github.com": reference }); + assert.deepEqual(loaded.usageLimitSources[sourceId]?.managementKey, usageReference); assert.notInclude(JSON.stringify(loaded), "stale-secret"); assert.equal((yield* service.getRoutingSettings).modelPrivacy, "private"); const saved = yield* service.updateSettings({ @@ -177,7 +184,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(disk.usageLimitSources, fixture.usageLimitSources); assert.deepEqual(disk.usageModelFilter, fixture.usageModelFilter); for (const tokens of [ - { "github.com": "replacement" }, { "github.com": "", " GitHub.COM ": "replacement" }, { "github.com": "", " GitHub.COM ": "••••••" }, ]) { @@ -474,7 +480,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }); assert.deepStrictEqual( - ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + ServerSettingsModule.redactServerSettingsForClient(settings, { secretReferences: true }) + .secretProviders, { personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, vault: { kind: "openbao", label: "Vault" }, @@ -1524,7 +1531,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { "quota-secret", "expo-secret", ]; - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); const disk = yield* fs.readFileString(config.settingsPath); for (const value of values) { assert.include(JSON.stringify(saved), value); @@ -1558,7 +1567,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { instance: { ...client.providerInstances[instanceId]!, config: { apiKey } }, }); assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey); - const redacted = ServerSettingsModule.redactServerSettingsForClient(changed); + const redacted = ServerSettingsModule.redactServerSettingsForClient(changed, { + secretReferences: true, + }); assert.propertyVal( redacted.providerInstances[instanceId]?.config, "apiKey", @@ -1607,9 +1618,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); assert.isTrue(Option.isNone(yield* store.get(secretNames[1]!))); assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).providerInstances[ - instanceId - ]?.environment, + ServerSettingsModule.redactServerSettingsForClient(referenced, { secretReferences: true }) + .providerInstances[instanceId]?.environment, [{ name: "API_TOKEN", value: secret, sensitive: true }], ); yield* service.updateSettings({ @@ -1660,7 +1670,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { "serverPassword", "legacy-password", ); - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); for (const value of ["legacy-api-key", "legacy-password", "admin_synthetic_billing_key"]) { assert.notInclude(yield* fs.readFileString(config.settingsPath), value); assert.notInclude(JSON.stringify(client), value); @@ -1705,8 +1717,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret); assert.include(raw, "me@example.com"); - const forClient = - ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts; + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }).sourceControlHosts; assert.equal(forClient.bitbucket?.email, "me@example.com"); assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access"); assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0); @@ -1746,20 +1759,68 @@ it.layer(NodeServices.layer)("server settings", (it) => { provider: SecretProviderId.make("personal"), reference: "op://Private/Bitbucket/token", }; + const gitHubReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/GitHub/token", + }; yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, + sourceControlHosts: { + bitbucket: { accessToken: "bb-access" }, + github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken", null))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "github.com"))); const referenced = yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: reference } }, + sourceControlHosts: { + bitbucket: { accessToken: reference }, + github: { tokens: { "github.com": gitHubReference, "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.deepEqual(referenced.sourceControlHosts.bitbucket?.accessToken, reference); assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); - assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts - .bitbucket?.accessToken, - reference, - ); + assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); + const redacted = ServerSettingsModule.redactServerSettingsForClient(referenced, { + secretReferences: true, + }).sourceControlHosts; + assert.deepEqual(redacted.bitbucket?.accessToken, reference); + const tokens = redacted.github?.tokens as Record; + assert.deepEqual(tokens["github.com"], gitHubReference); + assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe"); + + const usageReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Hub/key", + }; + const hub = UsageLimitSourceId.make("hub"); + const withUsage = yield* serverSettings.updateSettings({ + usageLimitSources: { + [hub]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: usageReference, + enabled: true, + }, + }, + }); + const legacy = ServerSettingsModule.redactServerSettingsForClient(withUsage, { + secretReferences: false, + }); + assert.notInclude(JSON.stringify(legacy), "op://"); + assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED); + assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED); + const echoed = yield* serverSettings.updateSettings({ + sourceControlHosts: legacy.sourceControlHosts, + usageLimitSources: legacy.usageLimitSources, + }); + assert.deepEqual(echoed.sourceControlHosts.bitbucket?.accessToken, reference); + assert.deepEqual(echoed.sourceControlHosts.github?.tokens, { + "github.com": gitHubReference, + "ghe.acme.test": "ghp_ghe", + }); + assert.deepEqual(echoed.usageLimitSources[hub]?.managementKey, usageReference); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ee4db5fa48acf..46a55d8fc7562 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -60,7 +60,12 @@ import { sourceControlHostSecretName, } from "./sourceControl/sourceControlHostSecrets.ts"; -import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; +import { + mapSettingsSecrets, + mapSettingsSecretValues, + SECRET_REDACTED, + type SettingsSecret, +} from "./settingsSecrets.ts"; import { DEFAULT_PROVIDER_INSTANCES } from "./provider/defaultProviderInstances.ts"; export { @@ -132,13 +137,35 @@ const normalizeServerSettings = ( ), ); -export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { +export function redactServerSettingsForClient( + settings: ServerSettings, + client: { readonly secretReferences: boolean }, +): ServerSettings { const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); - return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; + // Older clients decode secret fields as strings and fail on a reference, so they see a saved secret. + const shaped = client.secretReferences + ? redacted + : mapSettingsSecretValues(redacted, (value) => + isSecretReference(value) ? SECRET_REDACTED : value, + ); + return { ...shaped, secretProviders: withoutUnknownSecretProviderConfigs(shaped) }; +} + +/** A client shown a reference as a saved secret sends that marker back; it keeps the reference. */ +function withEchoedSecretReferences(current: ServerSettings, next: ServerSettings): ServerSettings { + const references = new Map(); + mapSettingsSecretValues(current, (value, secretName) => { + if (isSecretReference(value)) references.set(secretName, value); + return value; + }); + if (references.size === 0) return next; + return mapSettingsSecretValues(next, (value, secretName) => + value === SECRET_REDACTED ? (references.get(secretName) ?? value) : value, + ); } // A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. @@ -265,6 +292,12 @@ const applySettingsUpdate = Effect.fn("ServerSettings.applySettingsUpdate")(func mutation?: ProviderInstanceMutation, ) { if (mutation) yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath); + if (hasConflictingCredentialHosts(patch)) + return yield* new ServerSettingsError({ + settingsPath, + operation: "unsupported-secret-reference", + cause: "Multiple credential host keys identify the same host. Keep one entry per host.", + }); const patched = applyServerSettingsPatch(current, patch); const next = mutation ? applyProviderInstanceMutation(patched, mutation) : patched; yield* validateAccountRoutingPatch(current, next, patch, settingsPath); @@ -392,46 +425,8 @@ const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson const isSettingsObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown)); const decodeSettingsDocumentExit = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown)); -interface ForwardSettingsReference { - readonly path: ReadonlyArray; - readonly reference: SecretReference; - readonly secretName: string; -} - -const valueAtPath = (value: unknown, path: ReadonlyArray): unknown => { - for (const key of path) { - if (!isSettingsObject(value) || !Object.hasOwn(value, key)) return undefined; - value = value[key]; - } - return value; -}; - -const setAtPath = (value: Record, path: ReadonlyArray, next: unknown) => { - for (const key of path.slice(0, -1)) { - const child = Object.hasOwn(value, key) ? value[key] : undefined; - if (isSettingsObject(child)) { - value = child; - } else { - const created: Record = {}; - Object.defineProperty(value, key, { - value: created, - enumerable: true, - configurable: true, - writable: true, - }); - value = created; - } - } - Object.defineProperty(value, path[path.length - 1]!, { - value: next, - enumerable: true, - configurable: true, - writable: true, - }); -}; - const hasConflictingCredentialHosts = (settings: unknown) => { - const hosts = valueAtPath(settings, ["sourceControlHosts"]); + const hosts = isSettingsObject(settings) ? settings.sourceControlHosts : undefined; if (!isSettingsObject(hosts)) return false; for (const fields of Object.values(hosts)) { if (!isSettingsObject(fields) || !isSettingsObject(fields.tokens)) continue; @@ -445,67 +440,38 @@ const hasConflictingCredentialHosts = (settings: unknown) => { return false; }; -// A rollback keeps newer credentials opaque and unavailable until a supporting server returns. -const readForwardSettingsReferences = (raw: string) => { - const references: ForwardSettingsReference[] = []; +const readSettingsCompatibility = (raw: string) => { const decoded = decodeSettingsDocumentExit(raw); if (Exit.isFailure(decoded) || !isSettingsObject(decoded.value)) - return { raw, references, usageModelFilter: undefined, conflictingHosts: false }; + return { raw, usageModelFilter: undefined, conflictingHosts: false }; const document = decoded.value; - if (hasConflictingCredentialHosts(document)) { - return { raw, references, usageModelFilter: undefined, conflictingHosts: true }; - } - const keepReference = (path: ReadonlyArray, secretName: string) => { - const reference = valueAtPath(document, path); - if (!isSecretReference(reference)) return; - references.push({ path, reference, secretName }); - setAtPath(document, path, ""); - }; + if (hasConflictingCredentialHosts(document)) + return { raw, usageModelFilter: undefined, conflictingHosts: true }; const hosts = document.sourceControlHosts; if (isSettingsObject(hosts)) { - for (const [kind, fields] of Object.entries(hosts)) { + for (const fields of Object.values(hosts)) { if (!isSettingsObject(fields) || !isSettingsObject(fields.tokens)) continue; - for (const [host, reference] of Object.entries(fields.tokens)) { - if (!isSecretReference(reference)) continue; - const normalizedHost = host.trim().toLowerCase(); - Reflect.deleteProperty(fields.tokens, host); - const path = ["sourceControlHosts", kind, "tokens", normalizedHost]; - setAtPath(document, path, reference); - keepReference( - path, - sourceControlHostSecretName({ kind, field: "tokens", serverHost: normalizedHost }), - ); - } - } - } - const sources = document.usageLimitSources; - if (isSettingsObject(sources)) { - for (const id of Object.keys(sources)) { - keepReference( - ["usageLimitSources", id, "managementKey"], - `usage-limit-source-${Buffer.from(id).toString("base64url")}`, - ); + Object.assign(fields, { + tokens: Object.fromEntries( + Object.entries(fields.tokens).map(([host, value]) => [host.trim().toLowerCase(), value]), + ), + }); } } return { - raw: references.length > 0 ? JSON.stringify(document) : raw, - references, - usageModelFilter: document.usageModelFilter, + raw: JSON.stringify(document), + usageModelFilter: Object.hasOwn(ServerSettings.fields, "usageModelFilter") + ? undefined + : document.usageModelFilter, conflictingHosts: false, }; }; -const restoreForwardSettingsReferences = ( - raw: string, - references: ReadonlyArray, - usageModelFilter: unknown, -) => { - if (references.length === 0 && usageModelFilter === undefined) return raw; +const restoreForwardUsageModelFilter = (raw: string, usageModelFilter: unknown) => { + if (usageModelFilter === undefined) return raw; const document: unknown = JSON.parse(raw); if (!isSettingsObject(document)) throw new Error("Expected settings object"); - for (const { path, reference } of references) setAtPath(document, path, reference); - if (usageModelFilter !== undefined) setAtPath(document, ["usageModelFilter"], usageModelFilter); - return JSON.stringify(document, null, 2); + return JSON.stringify({ ...document, usageModelFilter }, null, 2); }; const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ @@ -813,7 +779,6 @@ const make = Effect.gen(function* () { ), ); - const protectedSettingsSecrets = new WeakMap>(); const conflictingHostsError = () => new ServerSettingsError({ settingsPath, @@ -822,18 +787,14 @@ const make = Effect.gen(function* () { }); const writeSettingsAtomically = Effect.fnUntraced( - function* ( - settings: ServerSettings, - references: ReadonlyArray = [], - usageModelFilter?: unknown, - ) { + function* (settings: ServerSettings, usageModelFilter?: unknown) { const sparseSettingsJson = yield* encodeServerSettingsJson( stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, ); return yield* writeFileStringAtomically({ filePath: settingsPath, - contents: `${restoreForwardSettingsReferences(sparseSettingsJson, references, usageModelFilter)}\n`, + contents: `${restoreForwardUsageModelFilter(sparseSettingsJson, usageModelFilter)}\n`, }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, pathService), @@ -884,12 +845,11 @@ const make = Effect.gen(function* () { let legacyProviders: Readonly> | undefined; let legacySourceControl: Parameters[1] = {}; let parseFailure = Option.none(); - let forwardReferences: ReadonlyArray = []; let forwardUsageModelFilter: unknown; if (yield* readConfigExists) { const raw = yield* readRawConfig; - const compatible = readForwardSettingsReferences(raw); + const compatible = readSettingsCompatibility(raw); if (compatible.conflictingHosts) return yield* conflictingHostsError(); const decoded = decodeServerSettingsJsonExit(compatible.raw); const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); @@ -914,14 +874,7 @@ const make = Effect.gen(function* () { } } else { settings = decoded.value; - forwardReferences = compatible.references; forwardUsageModelFilter = compatible.usageModelFilter; - if (forwardReferences.length > 0) { - yield* Effect.logWarning( - "newer settings credentials are unavailable in this server version", - { count: forwardReferences.length }, - ); - } } } // Settings saved by a server that kept the mode in `compactIdleThreads` move @@ -1023,16 +976,12 @@ const make = Effect.gen(function* () { (settingsFileTrusted && legacyProviders !== undefined) || hasLegacySourceControl ) { - yield* writeSettingsAtomically(migrated, forwardReferences, forwardUsageModelFilter); + yield* writeSettingsAtomically(migrated, forwardUsageModelFilter); for (const move of sourceControlMigration.secretMoves) { yield* secretStore.remove(move.from).pipe(Effect.ignore); } } - protectedSettingsSecrets.set( - migrated, - new Set(forwardReferences.map(({ secretName }) => secretName)), - ); - return { settings: migrated, parseFailure, forwardReferences, forwardUsageModelFilter }; + return { settings: migrated, parseFailure, forwardUsageModelFilter }; }); const settingsCache = yield* Cache.makeWith((_key: typeof cacheKey) => loadSettingsFromDisk, { @@ -1051,9 +1000,7 @@ const make = Effect.gen(function* () { function* (settings: ServerSettings): Effect.fn.Return { const storedSecrets = new Map(); const pending = new Map(); - const protectedSecrets = protectedSettingsSecrets.get(settings); const readableSettings = mapSettingsSecrets(settings, (secret) => { - if (protectedSecrets?.has(secret.secretName)) return { value: "", redacted: false }; if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret); return secret; }); @@ -1145,7 +1092,7 @@ const make = Effect.gen(function* () { }); const changes: SecretChange[] = []; const nextSecretNames = new Set(); - const settings = mapSettingsSecrets(next, (secret) => { + const settings = mapSettingsSecrets(withEchoedSecretReferences(current, next), (secret) => { const metadata = { secretName: secret.secretName, ...(secret.providerInstanceId !== undefined @@ -1274,12 +1221,7 @@ const make = Effect.gen(function* () { ): Effect.Effect => writeSemaphore.withPermits(1)( Effect.gen(function* () { - const { - settings: current, - parseFailure, - forwardReferences, - forwardUsageModelFilter, - } = yield* reloadSettings; + const { settings: current, parseFailure, forwardUsageModelFilter } = yield* reloadSettings; if (Option.isSome(parseFailure)) { return yield* new ServerSettingsError({ settingsPath, @@ -1290,30 +1232,19 @@ const make = Effect.gen(function* () { const candidate = yield* update(current); if (hasConflictingCredentialHosts(candidate)) return yield* conflictingHostsError(); const updated = mapSettingsSecrets(candidate, (secret) => secret); - if (forwardReferences.some(({ path }) => valueAtPath(updated, path) !== "")) { - return yield* new ServerSettingsError({ - settingsPath, - operation: "unsupported-secret-reference", - cause: "Update this credential with a server that supports secret references", - }); - } const persisted = yield* persistSettingsSecrets(current, updated); - const protectedSecrets = new Set(forwardReferences.map(({ secretName }) => secretName)); const next = yield* normalizeServerSettings(persisted.settings); - protectedSettingsSecrets.set(next, protectedSecrets); const materializedNext = yield* Effect.uninterruptibleMask(() => Effect.gen(function* () { yield* invalidateMaterialized; - const rollbackSecretChanges = yield* applySettingsSecretChanges( - persisted.changes.filter(({ secretName }) => !protectedSecrets.has(secretName)), - ); + const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); if (Exit.isFailure(materializedExit)) { yield* rollbackSecretChanges; return yield* Effect.failCause(materializedExit.cause); } const writeExit = yield* Effect.exit( - writeSettingsAtomically(next, forwardReferences, forwardUsageModelFilter), + writeSettingsAtomically(next, forwardUsageModelFilter), ); if (Exit.isFailure(writeExit)) { yield* rollbackSecretChanges; @@ -1325,7 +1256,6 @@ const make = Effect.gen(function* () { yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none(), - forwardReferences, forwardUsageModelFilter, }); const resolved = resolveTextGenerationProvider(materializedNext); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3d4daa659e58a..e94b6145782f8 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -3,7 +3,9 @@ import { ClaudeSettings, CodexSettings, EasDeviceHostConfig, + type ProviderInstanceConfig, type ServerSettings, + type SettingsSecretValue, SimlockDeviceHostConfig, UsageLimitSourceConfig, isSecretReference, @@ -49,16 +51,19 @@ export interface SettingsSecret { } type SecretValue = Pick; +type SecretSlotName = (field: string, serverHost: string | null) => string; +type SecretFieldsMapper = ( + fields: Readonly>, + secretFields: ReadonlyArray, + name: SecretSlotName, +) => Record; +type ProviderEnvironment = NonNullable; export function mapSettingsSecrets( settings: ServerSettings, map: (secret: SettingsSecret) => SecretValue, ): ServerSettings { - const mapFields = ( - fields: Readonly>, - secretFields: ReadonlyArray, - name: (field: string, serverHost: string | null) => string, - ) => { + const mapFields: SecretFieldsMapper = (fields, secretFields, name) => { const next = { ...fields }; for (const field of secretFields) { if (!(field in next) || isSecretReference(next[field])) continue; @@ -79,6 +84,70 @@ export function mapSettingsSecrets( return next; }; + return mapSecretFieldGroups(settings, mapFields, (instanceId, environment) => + environment.map((variable) => { + const { valueRedacted: _omit, ...rest } = variable; + // A secret reference is not itself secret; storage sees the value cleared. + const reference = typeof variable.value === "string" ? undefined : variable.value; + const result = map({ + secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, + kind: "environment", + value: typeof variable.value === "string" ? variable.value : "", + redacted: reference === undefined && variable.valueRedacted === true, + sensitive: variable.sensitive, + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); + if (reference !== undefined) return { ...rest, value: reference }; + return { + ...rest, + value: result.value, + ...(result.redacted ? { valueRedacted: true } : {}), + }; + }), + ); +} + +/** + * Maps each value a secret field holds, literal or reference, with the secret name of its slot. + * Provider environment variables are left as they are. + */ +export function mapSettingsSecretValues( + settings: ServerSettings, + map: (value: SettingsSecretValue, secretName: string) => SettingsSecretValue, +): ServerSettings { + const mapValue = (value: unknown, secretName: string) => + typeof value === "string" || isSecretReference(value) ? map(value, secretName) : value; + return mapSecretFieldGroups(settings, (fields, secretFields, name) => { + const next = { ...fields }; + for (const field of secretFields) { + if (!(field in next)) continue; + const value = next[field]; + next[field] = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !isSecretReference(value) + ? Object.fromEntries( + Object.entries(value).map(([serverHost, entry]) => [ + serverHost, + mapValue(entry, name(field, serverHost.trim().toLowerCase())), + ]), + ) + : mapValue(value, name(field, null)); + } + return next; + }); +} + +function mapSecretFieldGroups( + settings: ServerSettings, + mapFields: SecretFieldsMapper, + mapEnvironment: (instanceId: string, environment: ProviderEnvironment) => ProviderEnvironment = ( + _instanceId, + environment, + ) => environment, +): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => { const config = instance.config; @@ -98,28 +167,7 @@ export function mapSettingsSecrets( } : {}), ...(instance.environment - ? { - environment: instance.environment.map((variable) => { - const { valueRedacted: _omit, ...rest } = variable; - // A secret reference is not itself secret; storage sees the value cleared. - const reference = typeof variable.value === "string" ? undefined : variable.value; - const result = map({ - secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, - kind: "environment", - value: typeof variable.value === "string" ? variable.value : "", - redacted: reference === undefined && variable.valueRedacted === true, - sensitive: variable.sensitive, - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - if (reference !== undefined) return { ...rest, value: reference }; - return { - ...rest, - value: result.value, - ...(result.redacted ? { valueRedacted: true } : {}), - }; - }), - } + ? { environment: mapEnvironment(instanceId, instance.environment) } : {}), }, ]; diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index bfbe4a1c8ac94..697e166c324a4 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { SecretProviderId } from "@t3tools/contracts"; +import { SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; @@ -14,7 +14,14 @@ const source = (reference: string) => ({ reference, }); -const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); +const secretsByReference = new Map([ + ["bitbucket#token", "bb_from_provider"], + ["github#token", "ghp_from_provider"], +]); + +const perHostHosts = { + github: { tokens: { "github.com": source("github#token"), "ghe.acme.test": "ghp_literal" } }, +}; const hosts = { bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, @@ -69,11 +76,25 @@ it.effect("leaves the resolver unused for a kind that holds no reference", () => }), ); +it.effect("replaces a per-host token reference and keeps the literal beside it", () => + Effect.gen(function* () { + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, perHostHosts, "github"); + assert.deepEqual(resolved, { + github: { tokens: { "github.com": "ghp_from_provider", "ghe.acme.test": "ghp_literal" } }, + }); + assert.deepEqual(reads, ["github#token"]); + }), +); + it("collects the references held in credential fields and skips literal values", () => { assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [ source("missing#token"), source("bitbucket#token"), ]); + assert.deepEqual(collectSourceControlHostSecretReferences(perHostHosts), [ + source("github#token"), + ]); assert.deepEqual( collectSourceControlHostSecretReferences({ bitbucket: { accessToken: "literal" }, @@ -104,12 +125,31 @@ it.effect("reads a collected reference again once it is invalidated", () => }), }); const read = () => resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); + const usageLimitSources = { + [UsageLimitSourceId.make("referenced")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: source("cliproxy#key"), + enabled: true, + }, + [UsageLimitSourceId.make("literal")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: "plain-key", + enabled: true, + }, + } as const; + cache.set("cliproxy#key", "stale"); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "stale", }); current = "rotated"; - yield* invalidateSettingsSecrets(resolver, Effect.succeed({ sourceControlHosts: hosts })); + yield* invalidateSettingsSecrets( + resolver, + Effect.succeed({ sourceControlHosts: hosts, usageLimitSources }), + ); + assert.isFalse(cache.has("cliproxy#key")); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "rotated", diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index cf86ade6b0eee..32d2f41f9c504 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -53,7 +53,16 @@ export function writeSecretFieldValues( ): unknown { if (typeof original === "string") return values[0]?.[1] ?? ""; if (isSecretReference(original)) return original; - return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); + const sources = + original !== null && typeof original === "object" && !Array.isArray(original) + ? Object.entries(original).flatMap(([serverHost, entry]) => + isSecretReference(entry) ? [[serverHost.trim().toLowerCase(), entry] as const] : [], + ) + : []; + return Object.fromEntries([ + ...sources, + ...values.map(([serverHost, value]) => [serverHost ?? "", value] as const), + ]); } type SourceControlHostsSettings = Readonly>>>; @@ -88,13 +97,23 @@ export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.reso }, ); +const collectUsageLimitSecretReferences = ( + sources: ServerSettings["usageLimitSources"], +): ReadonlyArray => + Object.values(sources).flatMap(({ managementKey }) => + isSecretReference(managementKey) ? [managementKey] : [], + ); + /** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( resolver: SecretResolver.SecretResolver["Service"], - settings: Effect.Effect, E>, + settings: Effect.Effect, E>, ) { const references = yield* settings.pipe( - Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), + Effect.map((current) => [ + ...collectSourceControlHostSecretReferences(current.sourceControlHosts), + ...collectUsageLimitSecretReferences(current.usageLimitSources), + ]), Effect.orElseSucceed(() => undefined), ); yield* resolver.invalidate(references); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index b8976fca81e7d..1d955d497ef6b 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,10 +1,11 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -36,6 +37,10 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => ); }).pipe( Effect.provide([ + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => Effect.succeed(references.map(() => undefined)), + invalidate: () => Effect.void, + }), ServerSettings.layerTest({ usageLimitSources: { [UsageLimitSourceId.make("local-hub")]: { @@ -83,3 +88,66 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => Effect.scoped, ), ); + +it.effect("reads a management key from its secret provider", () => { + const authorizations: Array = []; + const keySource = (reference: string) => ({ + provider: SecretProviderId.make("homelab"), + reference, + }); + return Effect.gen(function* () { + const sources = yield* UsageLimitSources.make; + yield* sources.refresh; + const snapshots = yield* sources.current; + expect(snapshots.map(({ id, error }) => ({ id, error }))).toEqual([ + { id: "resolved-hub", error: undefined }, + { + id: "unresolved-hub", + error: "Could not read the management key from its secret provider.", + }, + ]); + expect(authorizations).toEqual(["Bearer resolved-key"]); + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("resolved-hub")]: { + kind: "cliproxy", + url: "https://resolved.test", + managementKey: keySource("cliproxy#key"), + enabled: true, + }, + [UsageLimitSourceId.make("unresolved-hub")]: { + kind: "cliproxy", + url: "https://unresolved.test", + managementKey: keySource("missing#key"), + enabled: true, + }, + }, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Effect.succeed( + references.map((reference) => + reference.reference === "cliproxy#key" ? "resolved-key" : undefined, + ), + ), + invalidate: () => Effect.void, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + authorizations.push(request.headers.authorization); + return HttpClientResponse.fromWeb(request, Response.json({ files: [] })); + }), + ), + ), + NodeCrypto.layer, + ]), + Effect.scoped, + ); +}); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7a068cde85f6c..7d40b0fb9ecea 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -4,7 +4,8 @@ import { type UsageLimitSourceConsumeResetCreditInput, type ProviderConsumeResetCreditResult, type ServerSettings, - type UsageLimitSourceConfig, + UsageLimitSourceConfig, + settingsSecretLiteral, type UsageLimitSourceId, type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; @@ -21,6 +22,9 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; +import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import * as Settings from "../serverSettings.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; @@ -46,11 +50,24 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { } } +function missingManagementKey(config: UsageLimitSourceConfig): string { + return typeof config.managementKey === "string" + ? "No management key configured." + : "Could not read the management key from its secret provider."; +} + /** @public */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* Settings.ServerSettingsService; + const secrets = yield* SecretResolver.SecretResolver; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* ( + config: UsageLimitSourceConfig, + ) { + const resolved = yield* resolveSettingsSecrets(secrets, UsageLimitSourceConfig, config); + return settingsSecretLiteral(resolved.managementKey); + }); const stateRef = yield* Ref.make>([]); const changes = yield* Effect.acquireRelease( PubSub.unbounded>(), @@ -63,10 +80,13 @@ export const make = Effect.gen(function* () { ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; - if (config.managementKey.length === 0) { - return { ...base, accounts: [], error: "No management key configured." }; + const managementKey = yield* readManagementKey(config); + if (managementKey.length === 0) { + return { ...base, accounts: [], error: missingManagementKey(config) }; } - const accounts = yield* api.readAccounts(config).pipe(Effect.result); + const accounts = yield* api + .readAccounts({ url: config.url, managementKey }) + .pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); return { ...base, accounts: [], error: accounts.failure.detail }; @@ -108,12 +128,20 @@ export const make = Effect.gen(function* () { Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })), ); const config = settings.usageLimitSources[input.sourceId]; - if (!config?.enabled || !config.managementKey) { + if (!config?.enabled) { return yield* new UsageLimitSourceError({ detail: "The usage limit source is missing or disabled.", }); } - const result = yield* api.consume(config, input.accountId, input.creditId); + const managementKey = yield* readManagementKey(config); + if (managementKey.length === 0) { + return yield* new UsageLimitSourceError({ detail: missingManagementKey(config) }); + } + const result = yield* api.consume( + { url: config.url, managementKey }, + input.accountId, + input.creditId, + ); const snapshot = yield* readSource(input.sourceId, config); const previous = yield* Ref.get(stateRef); yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); @@ -167,4 +195,6 @@ export const make = Effect.gen(function* () { } satisfies UsageLimitSources["Service"]; }); -export const layer = Layer.effect(UsageLimitSources, make); +export const layer = Layer.effect(UsageLimitSources, make).pipe( + Layer.provide(SecretResolverLayer.layer), +); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 91504d8002295..a4971957f2d3d 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -18,6 +18,12 @@ import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +/** A hub with its management key already read from wherever the setting keeps it. */ +export interface CliproxyHub { + readonly url: UsageLimitSourceConfig["url"]; + readonly managementKey: string; +} + const AuthFile = Schema.Struct({ id: Schema.String, auth_index: Schema.String, @@ -162,7 +168,7 @@ export const makeCliproxyApi = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const management = Effect.fn("CliproxyApi.management")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, path: string, body?: unknown, ) { @@ -170,13 +176,13 @@ export const makeCliproxyApi = Effect.gen(function* () { try: () => new URL( `v0/management/${path}`, - config.url.endsWith("/") ? config.url : `${config.url}/`, + hub.url.endsWith("/") ? hub.url : `${hub.url}/`, ).toString(), catch: () => new UsageLimitSourceError({ detail: "The hub URL is not valid." }), }); const request = ( body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url) - ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${config.managementKey}`)); + ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${hub.managementKey}`)); const response = yield* client .execute(body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body))) .pipe( @@ -190,13 +196,13 @@ export const makeCliproxyApi = Effect.gen(function* () { return response; }); - const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) { - const response = yield* management(config, "auth-files"); + const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (hub: CliproxyHub) { + const response = yield* management(hub, "auth-files"); return (yield* decodeAuthFiles(response)).files; }); const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, url: string, data?: unknown, @@ -218,7 +224,7 @@ export const makeCliproxyApi = Effect.gen(function* () { // The OAuth usage endpoint can return 429 without a Claude Code user agent. "User-Agent": "claude-code/2.1.273", }; - const raw = yield* management(config, "api-call", { + const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, method: data === undefined ? "GET" : "POST", url, @@ -235,10 +241,10 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const credits = Effect.fn("CliproxyApi.credits")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, ) { - const body = yield* apiCall(config, account, CREDIT_URL); + const body = yield* apiCall(hub, account, CREDIT_URL); const response = yield* decodeCreditList(body); const now = DateTime.toEpochMillis(yield* DateTime.now); return response.credits @@ -252,7 +258,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -266,7 +272,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage"); + const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); const usage = yield* decodeClaudeUsage(body); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" @@ -295,7 +301,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }).limits, }; } - const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`); + const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`); const usage = yield* decodeCodexUsage(body); const toWindow = (window: typeof CodexWindow.Type | null | undefined) => window @@ -307,7 +313,7 @@ export const makeCliproxyApi = Effect.gen(function* () { : { windowDurationMins: window.limit_window_seconds / 60 }), } : null; - const available = yield* credits(config, account).pipe(Effect.orElseSucceed(() => undefined)); + const available = yield* credits(hub, account).pipe(Effect.orElseSucceed(() => undefined)); const next = available?.[0]; const planType = usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; @@ -352,9 +358,9 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, ): Effect.fn.Return, UsageLimitSourceError> { - const accounts = yield* authFiles(config).pipe( + const accounts = yield* authFiles(hub).pipe( Effect.mapError((error) => isUsageLimitSourceError(error) ? error @@ -366,24 +372,24 @@ export const makeCliproxyApi = Effect.gen(function* () { (account) => !account.disabled && (account.provider === "codex" || account.provider === "claude"), ), - (account) => readAccount(config, account), + (account) => readAccount(hub, account), { concurrency: 4 }, ); }); const consume = Effect.fn("CliproxyApi.consume")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, accountId: string, creditId: string, ): Effect.fn.Return { const operation = Effect.gen(function* () { - const account = (yield* authFiles(config)).find((account) => account.id === accountId); + const account = (yield* authFiles(hub)).find((account) => account.id === accountId); if (!account || account.disabled || account.provider !== "codex") { return yield* new UsageLimitSourceError({ detail: "The Codex hub account is missing or disabled.", }); } - const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, { + const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { redeem_request_id: yield* creditRedeemRequestId( account.id_token?.chatgpt_account_id ?? account.id, creditId, @@ -400,7 +406,7 @@ export const makeCliproxyApi = Effect.gen(function* () { } as const )[response.code]; if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; - const cleared = yield* management(config, "reset-quota", { + const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index, }).pipe(Effect.result); return { diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 98b28b1ee038d..b3d5c307e43e1 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -673,6 +673,8 @@ it.effect( {}, yield* ServerBrowser.ServerBrowser, () => Effect.void, + + { secretReferences: true }, ).pipe( Layer.provide(Layer.mock(LocalThreadHandoff.LocalThreadHandoff)({})), Layer.provide(UpstreamUpdates.layer), @@ -712,8 +714,8 @@ it.effect( key: "mod+alt+z", command: "terminal.split", }); - const expectedSettings = [duringDiscovery, latestSettings].map( - ServerSettings.redactServerSettingsForClient, + const expectedSettings = [duringDiscovery, latestSettings].map((value) => + ServerSettings.redactServerSettingsForClient(value, { secretReferences: true }), ); const expectedKeybindings = [duringAck, latestKeybindings]; // The two sources can interleave, but each must retain its own publication order. @@ -845,6 +847,8 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( {}, yield* ServerBrowser.ServerBrowser, () => Effect.void, + + { secretReferences: true }, ).pipe( Layer.provide(Layer.mock(LocalThreadHandoff.LocalThreadHandoff)({})), Layer.provide(UpstreamUpdates.layer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c475b69cf96c4..f786723e9611f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -60,6 +60,8 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_V2_WS_METHODS, ORCHESTRATION_PROTOCOL_QUERY_PARAM, + CLIENT_CAPABILITIES_QUERY_PARAM, + SETTINGS_SECRET_REFERENCES_CAPABILITY, ORCHESTRATION_PROTOCOL_VERSION, OrchestrationV2DispatchCommandError, OrchestrationV2GetShellSnapshotError, @@ -680,6 +682,11 @@ function hasCompatibleOrchestrationProtocol(url: URL): boolean { ); } +function readSettingsClient(url: URL) { + const capabilities = url.searchParams.get(CLIENT_CAPABILITIES_QUERY_PARAM)?.split(",") ?? []; + return { secretReferences: capabilities.includes(SETTINGS_SECRET_REFERENCES_CAPABILITY) }; +} + function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; readonly acceptDeferredWork?: boolean; @@ -1265,6 +1272,7 @@ export const layerWsRpc = ( clientAnalyticsProps: Readonly>, serverBrowser: ServerBrowser.ServerBrowser["Service"], reportProfiles: ServerBrowser.ProfileReporter, + settingsClient: { readonly secretReferences: boolean }, ) => Layer.effectContext( Effect.gen(function* () { @@ -1783,6 +1791,7 @@ export const layerWsRpc = ( : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, + settingsClient, ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -1845,6 +1854,7 @@ export const layerWsRpc = ( threadFindProgressive: true, secretProviderSettings: true, secretReferences: true, + settingsSecretReferences: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), @@ -2358,13 +2368,13 @@ export const layerWsRpc = ( { concurrency: "unbounded", discard: true }, ); } + if (input.reloadSecrets === true && input.instanceId === undefined) { + yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); + } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } - if (input.reloadSecrets === true && input.instanceId === undefined) { - yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); - } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, @@ -2559,7 +2569,11 @@ export const layerWsRpc = ( [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, [WS_METHODS.serverGetDatabaseStorage]: () => databaseStorage.status, [WS_METHODS.serverGetSettings]: (_input) => - serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), + serverSettings.getSettings.pipe( + Effect.map((settings) => + ServerSettings.redactServerSettingsForClient(settings, settingsClient), + ), + ), [WS_METHODS.accountRoutingPreview]: (input) => accountRouting.preview(input), [WS_METHODS.peerLinksList]: (_input) => peerLinks.list.pipe(Effect.map((links) => ({ links }))), @@ -2582,7 +2596,7 @@ export const layerWsRpc = ( const settings = yield* providerInstanceMutation === undefined ? serverSettings.updateSettings(nextPatch) : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); - return ServerSettings.redactServerSettingsForClient(settings); + return ServerSettings.redactServerSettingsForClient(settings, settingsClient); }), [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, @@ -3231,7 +3245,9 @@ export const layerWsRpc = ( ) : Stream.empty; const settingsUpdates = settingsChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), + Stream.map((settings) => + ServerSettings.redactServerSettingsForClient(settings, settingsClient), + ), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, @@ -3446,7 +3462,13 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc(session, clientAnalyticsProps, serverBrowser, reportProfiles).pipe( + layerWsRpc( + session, + clientAnalyticsProps, + serverBrowser, + reportProfiles, + readSettingsClient(requestUrl.value), + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index e3abd8b951067..2106b0e407e0f 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; +import type { EnvironmentId, SecretReference, UnifiedSettings } from "@t3tools/contracts"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; @@ -14,7 +14,7 @@ import { } from "../ui/dialog"; import { Input } from "../ui/input"; import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; -import { SecretInput } from "./SecretInput"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; import { Label } from "../ui/label"; /** Send the management key once to server secret storage; settings retain only a redaction marker. */ @@ -34,14 +34,16 @@ export function AddUsageLimitSourceDialog({ const updateSettings = useUpdateEnvironmentSettings(environmentId); const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); - const [managementKey, setManagementKey] = useState(""); + const secretSources = useSecretSources(environmentId, "settingsSecretReferences"); + const [keyDraft, setKeyDraft] = useState(""); + const managementKey = typeof keyDraft === "string" ? keyDraft.trim() : keyDraft; const trimmedUrl = url.trim(); - const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; + const canSave = trimmedUrl.length > 0 && managementKey !== ""; const reset = () => { setLabel(""); setUrl(""); - setManagementKey(""); + setKeyDraft(""); }; const save = () => { @@ -53,7 +55,7 @@ export function AddUsageLimitSourceDialog({ kind: "cliproxy", ...(label.trim() ? { label: label.trim() } : {}), url: trimmedUrl, - managementKey: managementKey.trim(), + managementKey, enabled: true, }, }, @@ -98,10 +100,15 @@ export function AddUsageLimitSourceDialog({
- setManagementKey(event.target.value)} + aria-label="Management key" + value={keyDraft} + sources={secretSources} + onChange={(event) => setKeyDraft(event.target.value)} + onSecretChange={(secret) => setKeyDraft(secret ?? "")} + onProviderPick={() => setKeyDraft("")} + onReferenceReject={() => setKeyDraft("")} />
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 492b6ed85280c..489c79b85535a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -18,6 +18,7 @@ import * as Result from "effect/Result"; import { Fragment, useState, type ReactElement, type ReactNode } from "react"; import { isProviderDriverKind, + isSecretReference, resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, @@ -1198,8 +1199,8 @@ export function ProviderInstanceCard({ : undefined; const billingConnected = instance.driver === "claudeAgent" && - typeof consoleBilling === "string" && - consoleBilling.length > 0; + ((typeof consoleBilling === "string" && consoleBilling.length > 0) || + isSecretReference(consoleBilling)); const runtimeFields = driverOption ? ( <> { it("moves a reference to another provider only once one is committed for it", async () => { const onSecretChange = vi.fn(); + const onProviderPick = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} + onProviderPick={onProviderPick} />, ); await userEvent.click(screen.getByRole("combobox", { name: "API key source" })); await userEvent.click(screen.getByRole("option", { name: "Work" })); expect(onSecretChange).not.toHaveBeenCalled(); + expect(onProviderPick).toHaveBeenCalledOnce(); await expect.element(reference(screen)).toHaveValue(""); await commitReference(screen, "op://Shared/OpenAI/credential"); @@ -57,6 +60,8 @@ describe("SecretSourceInput", () => { />, ); + await commitReference(screen, "invalid-reference"); + await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await userEvent.clear(reference(screen)); await userEvent.keyboard("{Enter}"); expect(onSecretChange).toHaveBeenCalledExactlyOnceWith(undefined); @@ -64,6 +69,7 @@ describe("SecretSourceInput", () => { it("rejects a reference 1Password cannot read", async () => { const onSecretChange = vi.fn(); + const onReferenceReject = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} + onReferenceReject={onReferenceReject} />, ); await commitReference(screen, "Private/OpenAI/credential"); expect(onSecretChange).not.toHaveBeenCalled(); + expect(onReferenceReject).toHaveBeenCalledOnce(); + await screen.rerender( + {}} + onSecretChange={onSecretChange} + onReferenceReject={onReferenceReject} + />, + ); await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential"); + await commitReference(screen, "op://Private/OpenAI/corrected"); + expect(onSecretChange).toHaveBeenCalledExactlyOnceWith({ + provider: personal, + reference: "op://Private/OpenAI/corrected", + }); }); }); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 2fed001b09e91..666f8f49d0247 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -32,11 +32,14 @@ const STORED_VALUE = "__stored__"; const decodeSecretReference = Schema.decodeUnknownOption(SecretReference); /** Secret providers a setting can point at; empty when the environment cannot read references. */ -export function useSecretSources(environmentId: EnvironmentId): ReadonlyArray { +export function useSecretSources( + environmentId: EnvironmentId, + capability: "secretReferences" | "settingsSecretReferences" = "secretReferences", +): ReadonlyArray { const providers = useEnvironmentSettings(environmentId, (settings) => settings.secretProviders); const supported = useAtomValue( serverEnvironment.configValueAtom(environmentId), - (config) => config?.secretReferences === true, + (config) => config?.[capability] === true, ); if (!isSecretsFeatureEnabled || !supported) return NO_SECRET_SOURCES; return (Object.entries(providers) as Array<[SecretProviderId, SecretProviderConfig]>) @@ -64,6 +67,10 @@ type SecretSourceInputProps = Omit, "value" | readonly value: string | SecretReference; readonly sources: ReadonlyArray; readonly onSecretChange: (secret: SecretReference | undefined) => void; + /** Called when a provider is picked, before a reference for it exists. */ + readonly onProviderPick?: () => void; + /** Called when an edited reference is rejected, so the committed one no longer matches the field. */ + readonly onReferenceReject?: () => void; /** Places the parts in the caller's layout; `source` is null when there is nothing to pick. */ readonly children?: (parts: { readonly source: ReactNode; @@ -76,6 +83,8 @@ export function SecretSourceInput({ value, sources, onSecretChange, + onProviderPick, + onReferenceReject, children, ...rest }: SecretSourceInputProps) { @@ -106,6 +115,7 @@ export function SecretSourceInput({ return; } const next = value as SecretProviderId; + if (next !== secret?.provider) onProviderPick?.(); setPendingProvider(next === secret?.provider ? null : next); }} > @@ -159,6 +169,8 @@ export function SecretSourceInput({ definition?.isReference(decoded.value.reference) === false ) { setRejectedReference(next); + if (onReferenceReject !== undefined) setPendingProvider(provider); + onReferenceReject?.(); return; } setRejectedReference(null); diff --git a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx index 5662954ef7cd6..54b0018a249c7 100644 --- a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx +++ b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx @@ -101,7 +101,9 @@ export function SourceControlAppSignInSettings({ readHostCredentialSettings(settings, definition.kind), ); const signedIn = (signIns[host] ?? "").length > 0; - const tokenOverrides = (tokens[host] ?? "").length > 0; + const token = tokens[host]; + // A token read from a secret provider overrides the sign-in like a saved one. + const tokenOverrides = typeof token === "string" ? token.length > 0 : token !== undefined; const canSignIn = useAtomValue( sourceControlEnvironment.startSignIn.permissionAtom(environmentId), ); @@ -169,7 +171,7 @@ export function SourceControlAppSignInSettings({ /> ) : tokenOverrides ? ( - "Signed in, but the token saved below is used until you remove it." + "Signed in, but the token saved below is used while it can be read." ) : ( "Signed in" ); diff --git a/apps/web/src/components/settings/SourceControlTokenSettings.tsx b/apps/web/src/components/settings/SourceControlTokenSettings.tsx index 912de7ad91701..b1a2ddd3c2823 100644 --- a/apps/web/src/components/settings/SourceControlTokenSettings.tsx +++ b/apps/web/src/components/settings/SourceControlTokenSettings.tsx @@ -1,4 +1,9 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { + isSecretReference, + type EnvironmentId, + type SecretReference, + type SettingsSecretValue, +} from "@t3tools/contracts"; import { ExternalLinkIcon } from "lucide-react"; import { useState } from "react"; @@ -12,8 +17,11 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button, InlineButton } from "../ui/button"; import { Input } from "../ui/input"; -import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; + +const hasToken = (token: SettingsSecretValue | undefined) => + typeof token === "string" ? token.length > 0 : token !== undefined; export function SourceControlTokenSettings({ environmentId, @@ -34,15 +42,24 @@ export function SourceControlTokenSettings({ }); const defaultHost = definition.publicHost ?? ""; const [host, setHost] = useState(defaultHost); - const [draft, setDraft] = useState(""); + const secretSources = useSecretSources(environmentId, "settingsSecretReferences"); + const [pending, setPending] = useState<{ + readonly key: string; + readonly draft: string | SecretReference; + } | null>(null); const [saving, setSaving] = useState(false); const normalizedHost = host.trim().toLowerCase(); - const isSaved = (tokens[normalizedHost] ?? "").length > 0; + const savedToken = tokens[normalizedHost]; + const isSaved = hasToken(savedToken); const savedHosts = Object.entries(tokens) - .filter(([, value]) => value.length > 0) + .filter(([, value]) => hasToken(value)) .map(([saved]) => saved); + const sourceKey = JSON.stringify([normalizedHost, savedToken ?? ""]); + const draft = + pending?.key === sourceKey ? pending.draft : isSecretReference(savedToken) ? savedToken : ""; + const submission = typeof draft === "string" ? draft.trim() : draft; - const save = async (target: string, token: string) => { + const save = async (target: string, token: SettingsSecretValue) => { setSaving(true); const result = await updateSettings({ environmentId, @@ -55,7 +72,7 @@ export function SourceControlTokenSettings({ }); setSaving(false); if (result._tag === "Success") { - setDraft(""); + setPending(null); onSaved(); } }; @@ -65,7 +82,7 @@ export function SourceControlTokenSettings({ className="grid gap-3" onSubmit={(event) => { event.preventDefault(); - if (normalizedHost && draft.trim()) void save(normalizedHost, draft.trim()); + if (normalizedHost && submission !== "") void save(normalizedHost, submission); }} > {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */} @@ -106,12 +123,15 @@ export function SourceControlTokenSettings({
- setDraft(event.target.value)} + sources={secretSources} + onChange={(event) => setPending({ key: sourceKey, draft: event.target.value })} + onSecretChange={(secret) => setPending({ key: sourceKey, draft: secret ?? "" })} />
@@ -132,7 +152,11 @@ export function SourceControlTokenSettings({ Remove ) : null} - diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts index 3f9dc8f992454..239aed398d77a 100644 --- a/packages/client-runtime/src/connection/compatibility.test.ts +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -31,6 +31,7 @@ describe("orchestration protocol compatibility", () => { expect(socketUrl.searchParams.get("orchestrationProtocol")).toBe( String(ORCHESTRATION_PROTOCOL_VERSION), ); + expect(socketUrl.searchParams.get("clientCapabilities")).toBe("settingsSecretReferences"); expect(socketUrl.searchParams.get("wsTicket")).toBe("secret"); expect(socketUrl.searchParams.get("connectionMethod")).toBe("relay"); }); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts index 3ca2e226db954..fccc68bff751e 100644 --- a/packages/client-runtime/src/connection/compatibility.ts +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -1,6 +1,8 @@ import { + CLIENT_CAPABILITIES_QUERY_PARAM, ORCHESTRATION_PROTOCOL_QUERY_PARAM, ORCHESTRATION_PROTOCOL_VERSION, + SETTINGS_SECRET_REFERENCES_CAPABILITY, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -37,5 +39,6 @@ function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean { export function appendOrchestrationProtocol(socketUrl: string): string { const url = new URL(socketUrl); url.searchParams.set(ORCHESTRATION_PROTOCOL_QUERY_PARAM, String(ORCHESTRATION_PROTOCOL_VERSION)); + url.searchParams.set(CLIENT_CAPABILITIES_QUERY_PARAM, SETTINGS_SECRET_REFERENCES_CAPABILITY); return url.toString(); } diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index b70b2ef17615f..82a2aafa00140 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -202,7 +202,7 @@ describe("ConnectionResolver", () => { label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", + "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2&clientCapabilities=settingsSecretReferences", httpAuthorization: null, target, }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 0af0a0d8891ed..4faba348c92a4 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -15,6 +15,11 @@ export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; export const ORCHESTRATION_PROTOCOL_HEADER = "x-t3-orchestration-protocol"; +/** Wire shapes a client understands, comma-separated; a client that omits one gets the older shape. */ +export const CLIENT_CAPABILITIES_QUERY_PARAM = "clientCapabilities"; +/** Secret fields may hold a secret provider reference instead of a string. */ +export const SETTINGS_SECRET_REFERENCES_CAPABILITY = "settingsSecretReferences"; + export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", "linux", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c749881434819..33c5392fd1706 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -644,6 +644,8 @@ export const ServerConfig = Schema.Struct({ secretProviderSettings: Schema.optionalKey(Schema.Boolean), /** Secret settings may point at a secret provider; older servers drop the reference. */ secretReferences: Schema.optionalKey(Schema.Boolean), + /** Host-token maps and usage-limit keys accept secret provider references. */ + settingsSecretReferences: Schema.optionalKey(Schema.Boolean), scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e5c826cabc720..8c61597245fd4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -24,6 +24,7 @@ import { } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { @@ -875,7 +876,10 @@ export const UsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), url: TrimmedNonEmptyString, - managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting), + managementKey: SettingsSecretValue.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + secretSetting, + ), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; @@ -1698,7 +1702,7 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), - /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty or omitted secrets remove them. */ + /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty secrets remove them. A non-empty token map keeps the saved tokens of hosts it omits; an empty map clears them all. */ sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), // Replace whole maps to avoid half-merged driver configs. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), diff --git a/packages/contracts/src/settingsSecretValue.ts b/packages/contracts/src/settingsSecretValue.ts index 101c40998b6f0..97558c82d574f 100644 --- a/packages/contracts/src/settingsSecretValue.ts +++ b/packages/contracts/src/settingsSecretValue.ts @@ -26,26 +26,68 @@ export function acceptsSecretReference(schema: Schema.Top): boolean { return Schema.resolveAnnotationsKey(schema)?.settingsSecretReference === true; } -/** Fields of a settings struct typed `SettingsSecretValue`; any other schema has none. */ -function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { +const declaresSecretReference = (ast: SchemaAST.AST) => + ast.context?.annotations?.settingsSecretReference === true; + +interface SecretReferenceField { + readonly field: string; + readonly keyed: boolean; +} + +/** + * Fields of a settings struct typed `SettingsSecretValue`, or a record of it such as one value + * per host; any other schema has none. + */ +function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { if (!SchemaAST.isObjects(schema.ast)) return []; - return schema.ast.propertySignatures.flatMap((property) => - property.type.context?.annotations?.settingsSecretReference === true && - typeof property.name === "string" - ? [property.name] - : [], - ); + return schema.ast.propertySignatures.flatMap(({ name, type }) => { + if (typeof name !== "string") return []; + if (declaresSecretReference(type)) return [{ field: name, keyed: false }]; + return SchemaAST.isObjects(type) && + type.indexSignatures.some((signature) => declaresSecretReference(signature.type)) + ? [{ field: name, keyed: true }] + : []; + }); } -/** Each declared reference field of `settings` that holds a reference, in field order. */ +/** Where a reference sits: a field, or one key of a record field. */ +export interface SettingsSecretSlot { + readonly field: string; + readonly key: string | undefined; +} + +interface SettingsSecretReference extends SettingsSecretSlot { + readonly reference: SecretReference; +} + +/** Each declared reference slot of `settings` that holds a reference, in field order. */ export function secretReferencesInSettings( schema: Schema.Top, settings: unknown, -): ReadonlyArray<{ readonly field: string; readonly reference: SecretReference }> { +): ReadonlyArray { if (settings === null || typeof settings !== "object") return []; const values = settings as Readonly>; - return secretReferenceSettingsFields(schema).flatMap((field) => { - const value = values[field]; - return isSecretReference(value) ? [{ field, reference: value }] : []; - }); + return secretReferenceSettingsFields(schema).flatMap( + ({ field, keyed }) => { + const value = values[field]; + if (!keyed) + return isSecretReference(value) ? [{ field, key: undefined, reference: value }] : []; + if (value === null || typeof value !== "object") return []; + return Object.entries(value).flatMap(([key, entry]) => + isSecretReference(entry) ? [{ field, key, reference: entry }] : [], + ); + }, + ); +} + +/** `settings` with each slot set to its value, keeping the other keys of a record field. */ +export function withSettingsSecrets>>( + settings: Settings, + slots: ReadonlyArray, +): Settings { + const next: Record = { ...settings }; + for (const { field, key, value } of slots) { + next[field] = key === undefined ? value : { ...(next[field] as object), [key]: value }; + } + return next as Settings; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 26cbfb3df9d59..c795d2a4d5455 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -121,6 +121,24 @@ describe("serverSettings helpers", () => { }); }); + it("keeps a source control host's saved tokens when a patch sets another host's token", () => { + const reference = { provider: "vault", reference: "github/token" }; + const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + sourceControlHosts: { github: { tokens: { "github.com": reference } } }, + }); + const patched = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { "ghe.example": "x" } } }, + }); + expect(patched.sourceControlHosts.github?.tokens).toEqual({ + "github.com": reference, + "ghe.example": "x", + }); + const recased = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { " GitHub.com ": "ghp_new" } } }, + }); + expect(recased.sourceControlHosts.github?.tokens).toEqual({ "github.com": "ghp_new" }); + }); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { const project = { id: ProjectId.make("project-actions"), scripts: [] }; const action = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index fbd6447f52bed..075d3d3a4dd72 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -23,6 +23,7 @@ import { withIdleCompaction, } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; @@ -226,6 +227,15 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +// Secret persistence keys tokens by trimmed, lowercased host, so merge on the same key. +const byHost = (tokens: unknown) => + Object.fromEntries( + Object.entries(Predicate.isObject(tokens) ? tokens : {}).map(([host, token]) => [ + host.trim().toLowerCase(), + token, + ]), + ); + const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { @@ -537,10 +547,26 @@ export function applyServerSettingsPatch( sourceControlHosts: { ...current.sourceControlHosts, ...Object.fromEntries( - Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ - kind, - { ...current.sourceControlHosts[kind], ...fields }, - ]), + Object.entries(sourceControlHostsPatch).map(([kind, fields]) => { + const saved = current.sourceControlHosts[kind]; + return [ + kind, + { + ...saved, + ...fields, + // Older clients send the whole token map; a host missing from it keeps its saved + // token. An empty map still clears them all. + ...(Predicate.isObject(fields.tokens) && Object.keys(fields.tokens).length > 0 + ? { + tokens: { + ...byHost(saved?.tokens), + ...byHost(fields.tokens), + }, + } + : {}), + }, + ]; + }), ), }, } diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index c766a92a5a5f5..df202ab509ad5 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -3,6 +3,7 @@ import { type PullRequestReviewVerdict, type RepositoryIdentity, secretSetting, + SettingsSecretValue, SourceControlProviderKind, type SourceControlProviderInfo, TrimmedNonEmptyString, @@ -212,7 +213,7 @@ export const hostCredentialSettingsFields = { Schema.withDecodingDefault(Effect.succeed({})), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), - tokens: Schema.Record(SourceControlHostName, TrimmedString).pipe( + tokens: Schema.Record(SourceControlHostName, SettingsSecretValue).pipe( Schema.withDecodingDefault(Effect.succeed({})), secretSetting, Schema.annotateKey({ providerSettingsForm: { hidden: true } }), diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index 6dd703960707b..c161bbe14cd9c 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -18,7 +18,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import { readSourceControlHostSettings } from "@t3tools/source-control-core/shared/definition"; -import type { ServerSettings } from "@t3tools/contracts"; +import { settingsSecretLiteral, type ServerSettings } from "@t3tools/contracts"; import * as GitHubClient from "../shared/definition.ts"; @@ -203,8 +203,10 @@ export const make = Effect.gen(function* () { ); const savedToken = (host: string) => - sourceControlHost.settings.get.pipe( - Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null), + sourceControlHost.settings.getWithSecrets(GitHubClient.definition.kind).pipe( + Effect.map( + (settings) => settingsSecretLiteral(readSettings(settings).tokens[host]).trim() || null, + ), Effect.orElseSucceed(() => null), ); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 4a42e9422a5de..129a3888b0714 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -1,4 +1,4 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { settingsSecretLiteral, SourceControlProviderKind } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -225,7 +225,7 @@ export const makeDiscovery = Effect.gen(function* () { probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { const settings = readSourceControlHostSettings( GitHubClient.settings, - yield* sourceControlHost.settings.get.pipe( + yield* sourceControlHost.settings.getWithSecrets(GitHubClient.definition.kind).pipe( Effect.map((current) => current.sourceControlHosts[GitHubClient.definition.kind]), Effect.orElseSucceed(() => undefined), ), @@ -237,7 +237,8 @@ export const makeDiscovery = Effect.gen(function* () { }, spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); - const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; + const token = settings.tokens["github.com"]; + const savedToken = settingsSecretLiteral(token).trim() !== ""; const signedIn = appSignIn.enabled && !savedToken && (settings.signIns["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); diff --git a/packages/source-control-github/src/server/gitHubDiscovery.test.ts b/packages/source-control-github/src/server/gitHubDiscovery.test.ts index cd8a838b23723..b108d71f75584 100644 --- a/packages/source-control-github/src/server/gitHubDiscovery.test.ts +++ b/packages/source-control-github/src/server/gitHubDiscovery.test.ts @@ -1,10 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { ServerSettings, SourceControlProviderKind } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; @@ -79,3 +83,79 @@ it.effect("asks for no credential when the identity resolver left the base URL e assert.deepStrictEqual(hosts, []); }), ); + +const decodeSettings = Schema.decodeSync(ServerSettings); +const referencedTokenSettings = decodeSettings({ + sourceControlHosts: { + github: { tokens: { "github.com": { provider: "vault", reference: "op://v/gh" } } }, + }, +}); + +it.effect.each([ + { name: "readable", token: "resolved-token", source: "the token saved in Settings" }, + { name: "unreadable", token: "", source: "GH_TOKEN from the server environment" }, +])("checks the $name GitHub token reference against the API", (scenario) => + Effect.gen(function* () { + const paths: Array = []; + const discovery = yield* GitHubSourceControlProvider.makeDiscovery.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(GitHubRest.GitHubRest)({ + rest: (input) => { + paths.push(input.path); + return Effect.succeed({ + status: 200, + headers: {}, + body: JSON.stringify({ login: "octocat" }), + truncated: false, + invalidUtf8: false, + }); + }, + }), + Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ enabled: false }), + Layer.effect( + SourceControlHost.SourceControlHost, + Effect.gen(function* () { + const host = yield* SourceControlHost.SourceControlHost; + return { + ...host, + settings: { + ...host.settings, + getWithSecrets: () => + Effect.succeed( + decodeSettings({ + sourceControlHosts: { + github: { tokens: { "github.com": scenario.token } }, + }, + }), + ), + }, + }; + }), + ).pipe( + Layer.provide( + TestSourceControlHost.layer({ + settings: referencedTokenSettings, + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput("", { + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ), + }, + }), + ), + ), + ), + ), + Effect.provideService(HostProcess.Environment, { GH_TOKEN: "environment-token" }), + ); + if (discovery.type !== "managed-cli") return assert.fail("expected a managed CLI"); + const item = yield* discovery.probe("/repo"); + assert.deepStrictEqual(paths, ["user"]); + assert.strictEqual(item.auth.status, "authenticated"); + assert.deepStrictEqual(item.auth.account, Option.some("octocat")); + assert.include(Option.getOrThrow(item.auth.detail), scenario.source); + }), +); From eea8c66a04bd9a974927c4d34b39fe06d4595e34 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:42:19 -0600 Subject: [PATCH 03/25] fix: show account labels in thread lineage Adapted from upstream 0343c83aa6. (cherry picked from commit 90b5b599468af38112e2ed450266bc649ff27025) --- apps/mobile/src/components/ProviderIcon.tsx | 5 +- .../src/features/threads/SubagentRow.tsx | 50 +++++++++++++++---- .../chat/ThreadLineageWorkflowRow.tsx | 14 +++++- .../chat/ThreadRelationshipIcon.tsx | 18 +++++-- .../chat/ThreadRelationshipsControl.tsx | 16 ++++++ 5 files changed, 88 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index ae7f9f8a7cc64..dea5b1528fb88 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -11,6 +11,7 @@ import { resolveProviderIcon } from "@t3tools/provider-core/client"; import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./ui/AppText"; +import { cn } from "../lib/cn"; import { getProviderClient } from "@t3tools/client-runtime/provider-clients"; type ProviderIconProps = { @@ -161,7 +162,7 @@ export function ProviderInstanceIcon(props: { readonly displayName: string; readonly accentColor?: string; readonly showBadge?: boolean; - readonly surfaceColor: string; + readonly surfaceColor?: string; }) { const accentColor = normalizeProviderAccentColor(props.accentColor); return ( @@ -181,7 +182,7 @@ export function ProviderInstanceIcon(props: { style={{ position: "absolute", right: -3, bottom: -3, width: 32, alignItems: "flex-end" }} > { + const rawConfig = config?.settings.providerInstances[candidate.instanceId]?.config; + const instanceConfig = + rawConfig !== null && typeof rawConfig === "object" + ? (rawConfig as Readonly>) + : null; + const agentId = instanceConfig?.source === "local" ? undefined : instanceConfig?.agentId; + return { + ...candidate, + driverKind: candidate.driver, + accentColor: normalizeProviderAccentColor(candidate.accentColor), + acpRegistryAgentId: typeof agentId === "string" ? agentId.trim() || undefined : undefined, + }; + }); + const provider = providers.find( (candidate) => candidate.instanceId === subagent.providerInstanceId, ); + const showBadge = provider !== undefined && shouldShowInstanceBadge(provider, providers); const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source; const child = useThreadShell( subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId), @@ -123,12 +142,25 @@ function SubagentMetadata(props: { }); return ( - + {showBadge ? ( + + ) : ( + + )} + {provider ? `${resolveProviderInstanceDisplayName(provider)} · ` : ""} {modelLabel} diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index f1df96dd92133..4bc59403a7d33 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -10,6 +10,7 @@ import { AgentElapsed } from "./AgentElapsed"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; +import type { ProviderInstanceEntry } from "../../providerInstances"; import { cn } from "../../lib/utils"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; @@ -52,6 +53,7 @@ function WorkflowMemberRow({ providerInstanceId, provider, providers, + providerEntries, driver, onOpen, isThreadAvailable, @@ -60,6 +62,7 @@ function WorkflowMemberRow({ providerInstanceId: ProviderInstanceId; provider: ServerProvider | undefined; providers: ReadonlyArray | undefined; + providerEntries: ReadonlyArray; driver: ProviderDriverKind | undefined; onOpen: (threadId: string) => void; isThreadAvailable: (threadId: string) => boolean; @@ -80,7 +83,12 @@ function WorkflowMemberRow({ /> } > - + {member.title} {member.status} {member.startedAt ? ( @@ -115,6 +123,7 @@ export function ThreadLineageWorkflowRow({ providerInstanceId, provider, providers, + providerEntries, driver, onOpenThread, isThreadAvailable, @@ -127,6 +136,7 @@ export function ThreadLineageWorkflowRow({ readonly providerInstanceId: ProviderInstanceId; readonly provider: ServerProvider | undefined; readonly providers: ReadonlyArray | undefined; + readonly providerEntries: ReadonlyArray; readonly driver: ProviderDriverKind | undefined; readonly onOpenThread: (threadId: string) => void; readonly isThreadAvailable: (threadId: string) => boolean; @@ -225,6 +235,7 @@ export function ThreadLineageWorkflowRow({ providerInstanceId={providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={driver} onOpen={onOpenThread} isThreadAvailable={isThreadAvailable} @@ -242,6 +253,7 @@ export function ThreadLineageWorkflowRow({ providerInstanceId={providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={driver} onOpen={onOpenThread} isThreadAvailable={isThreadAvailable} diff --git a/apps/web/src/components/chat/ThreadRelationshipIcon.tsx b/apps/web/src/components/chat/ThreadRelationshipIcon.tsx index c591a241c75f3..9a1453af9856b 100644 --- a/apps/web/src/components/chat/ThreadRelationshipIcon.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipIcon.tsx @@ -1,6 +1,7 @@ import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; import { BotIcon, type LucideIcon } from "lucide-react"; import { cn } from "../../lib/utils"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; export function threadRelationshipStatusLabel(status: string | null): string { @@ -45,23 +46,33 @@ export function threadRelationshipStatusLabel(status: string | null): string { export function ThreadRelationshipIcon({ driver, provider, + providerEntries = [], status, fallbackIcon: FallbackIcon = BotIcon, }: { driver?: ProviderDriverKind | undefined; provider?: ServerProvider | undefined; + providerEntries?: ReadonlyArray; status: string | null; fallbackIcon?: LucideIcon; }) { const iconClassName = "size-4 shrink-0 text-muted-foreground"; + const entry = driver + ? providerEntries.find((candidate) => candidate.instanceId === provider?.instanceId) + : undefined; + const showInstanceBadge = entry !== undefined && shouldShowInstanceBadge(entry, providerEntries); return ( {driver ? ( @@ -70,7 +81,8 @@ export function ThreadRelationshipIcon({ )} (null); // Derive graphs after hooks to preserve their compiler cache. const providers = providersByEnvironment.get(props.environmentId); + const derivedProviderEntries = deriveProviderInstanceEntries(providers ?? []); + const providerEntries = settings + ? applyProviderInstanceSettings( + derivedProviderEntries, + settings, + currentThread?.projectId ?? null, + ) + : derivedProviderEntries; const projects = allProjects.filter((project) => project.environmentId === props.environmentId); const parentNativeStatus = parentProjection ? deriveProviderSubagentStatus(parentProjection)?.status @@ -674,6 +688,7 @@ export function ThreadRelationshipsPanel(props: { @@ -731,6 +746,7 @@ export function ThreadRelationshipsPanel(props: { providerInstanceId={agent.providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={providerDriver} onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)} isThreadAvailable={(memberThreadId) => From 0f854b86e6901379d3b627d3a43c2d65b656da78 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:43:47 -0600 Subject: [PATCH 04/25] fix: show account labels on subagent cards Adapted from upstream 2c59bf66c7. (cherry picked from commit 6287ca8cba7cfea90371ef4322a8d3a23bf3def5) --- apps/mobile/src/components/ProviderIcon.tsx | 2 +- .../threads/thread-subagent-group.tsx | 59 +++++++++++++++---- .../src/components/chat/V2LifecycleRow.tsx | 35 +++++++++-- apps/web/src/providerInstances.test.ts | 27 +++++++++ apps/web/src/providerInstances.ts | 13 ++-- .../src/state/providerInstanceDisplay.test.ts | 5 ++ .../src/state/providerInstanceDisplay.ts | 2 +- 7 files changed, 119 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index dea5b1528fb88..c218a39a3a912 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -189,7 +189,7 @@ export function ProviderInstanceIcon(props: { paddingHorizontal: 2, borderRadius: 999, borderWidth: 1, - borderColor: props.surfaceColor, + ...(props.surfaceColor ? { borderColor: props.surfaceColor } : {}), backgroundColor: accentColor, alignItems: "center", justifyContent: "center", diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index cabd4eeb371bb..4a42a83c9a371 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -1,19 +1,23 @@ import { useAtomValue } from "@effect/atom-react"; import { StackActions, useNavigation } from "@react-navigation/native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "@t3tools/client-runtime/state/provider-instance-display"; import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { EnvironmentId, OrchestrationV2Subagent, OrchestrationV2TurnItem, - ServerProvider, + ServerConfig, } from "@t3tools/contracts"; import { Pressable, View, type ColorValue } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { serverEnvironment } from "../../state/server"; @@ -42,21 +46,53 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray candidate.instanceId === props.item.providerInstanceId, + ); // A remote task's own driver is only a "remote" label. const driver = - props.provider?.driver ?? - (props.item.remoteChild === undefined ? props.item.driver : undefined); + provider?.driver ?? (props.item.remoteChild === undefined ? props.item.driver : undefined); + const badgeEntries = providers.map((candidate) => { + const rawConfig = props.config?.settings.providerInstances[candidate.instanceId]?.config; + const instanceConfig = + rawConfig !== null && typeof rawConfig === "object" + ? (rawConfig as Readonly>) + : null; + const agentId = instanceConfig?.source === "local" ? undefined : instanceConfig?.agentId; + return { + ...candidate, + driverKind: candidate.driver, + acpRegistryAgentId: typeof agentId === "string" ? agentId.trim() || undefined : undefined, + }; + }); + const badgeEntry = badgeEntries.find( + (candidate) => candidate.instanceId === props.item.providerInstanceId, + ); + const showBadge = + provider !== undefined && + badgeEntry !== undefined && + shouldShowInstanceBadge(badgeEntry, badgeEntries); return ( - {driver === undefined ? ( + {showBadge ? ( + + ) : driver === undefined ? ( ) : ( - + )} ); @@ -100,11 +136,8 @@ export function ThreadSubagentGroup(props: { const expanded = props.expandedRows[props.anchorKey] ?? false; const serverConfigs = useServerConfigs(); // A task in a linked environment runs a provider instance of that environment. - const providerOf = (item: SubagentItem) => - (item.remoteChild === undefined - ? config - : serverConfigs.get(item.remoteChild.environmentId) - )?.providers.find((provider) => provider.instanceId === item.providerInstanceId); + const configOf = (item: SubagentItem) => + item.remoteChild === undefined ? config : serverConfigs.get(item.remoteChild.environmentId); return ( {grouped ? ( @@ -118,7 +151,7 @@ export function ThreadSubagentGroup(props: { {agents.slice(0, 3).map((agent, index) => ( - + ))} {agents.length > 3 ? ( diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 2688cf77de6ae..f28501849913d 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -47,6 +47,7 @@ import { import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { applyProviderInstanceSettings, + brandProviderInstanceEntries, deriveProviderInstanceEntries, shouldShowInstanceBadge, type ProviderInstanceEntry, @@ -207,6 +208,8 @@ function SubagentAvatar({ instanceId, displayName, iconUrl, + accentColor, + showBadge, status, className, }: { @@ -214,6 +217,8 @@ function SubagentAvatar({ instanceId?: ProviderInstanceId | undefined; displayName?: string | undefined; iconUrl?: string | undefined; + accentColor?: string | undefined; + showBadge: boolean; status?: OrchestrationV2TurnItem["status"] | undefined; className?: string; }) { @@ -230,7 +235,10 @@ function SubagentAvatar({ driverKind={driver} instanceId={instanceId} displayName={displayName ?? driver} + accentColor={accentColor} acpRegistryIconUrl={iconUrl} + showBadge={showBadge} + size="compact" className="z-auto" iconClassName="size-3.5" /> @@ -240,7 +248,8 @@ function SubagentAvatar({ {status ? ( @@ -264,9 +273,13 @@ export function EnvironmentSubagentAvatar({ remote?: OrchestrationV2RemoteTaskChild | undefined; status?: OrchestrationV2TurnItem["status"] | undefined; }) { - const providersAtom = serverEnvironment.providersValueAtom( - remote?.environmentId ?? environmentId, - ); + const providerEnvironmentId = remote?.environmentId ?? environmentId; + const providersAtom = serverEnvironment.providersValueAtom(providerEnvironmentId); + const settings = useAtomValue(serverEnvironment.settingsValueAtom(providerEnvironmentId)); + const entries = (providers: ReadonlyArray | null | undefined) => { + const derived = deriveProviderInstanceEntries(providers ?? []); + return settings ? brandProviderInstanceEntries(derived, settings) : derived; + }; const providerDriver = useAtomValue( providersAtom, (providers) => @@ -275,8 +288,18 @@ export function EnvironmentSubagentAvatar({ const displayName = useAtomValue( providersAtom, (providers) => - providers?.find((provider) => provider.instanceId === providerInstanceId)?.displayName, + entries(providers).find((entry) => entry.instanceId === providerInstanceId)?.displayName, + ); + const accentColor = useAtomValue( + providersAtom, + (providers) => + entries(providers).find((entry) => entry.instanceId === providerInstanceId)?.accentColor, ); + const showBadge = useAtomValue(providersAtom, (providers) => { + const all = entries(providers); + const entry = all.find((candidate) => candidate.instanceId === providerInstanceId); + return entry !== undefined && shouldShowInstanceBadge(entry, all); + }); const iconUrl = useAtomValue( providersAtom, (providers) => @@ -289,6 +312,8 @@ export function EnvironmentSubagentAvatar({ driver={providerDriver ?? (remote === undefined ? driver : undefined)} displayName={displayName} iconUrl={iconUrl} + accentColor={accentColor} + showBadge={showBadge} /> ); } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a07ff9ae9b17e..891f3312ae833 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { applyProviderInstanceSettings, + brandProviderInstanceEntries, deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, getDefaultProviderInstanceModel, @@ -254,6 +255,32 @@ describe("deriveProviderEntriesByEnvironment", () => { }); }); +describe("brandProviderInstanceEntries", () => { + it("keeps an instance a project turns on as a sibling for the instance badge", () => { + const codex = ProviderDriverKind.make("codex"); + const settings = { + providerInstances: { + [ProviderInstanceId.make("codex_work")]: { driver: codex, enabled: false }, + }, + projectSettingsOverrides: { + [ProjectId.make("project-work")]: { + providerInstances: { [ProviderInstanceId.make("codex_work")]: { enabled: true } }, + }, + }, + }; + const entries = brandProviderInstanceEntries( + deriveProviderInstanceEntries([ + provider({ provider: codex, instanceId: "codex" }), + provider({ provider: codex, instanceId: "codex_work" }), + ]), + settings, + ); + const personal = entries.find((entry) => entry.instanceId === "codex"); + + expect(personal && shouldShowInstanceBadge(personal, entries)).toBe(true); + }); +}); + describe("resolveSelectableProviderInstance", () => { it("falls back to the first enabled and available instance", () => { const disabled = ProviderInstanceId.make("codex"); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 75ce4837675dd..cbeabb11ac2c5 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -93,10 +93,7 @@ export function deriveProviderEntriesByEnvironment( const byEnvironment = new Map>(); for (const [environmentId, providers, settings] of providersByEnvironment) { const derived = deriveProviderInstanceEntries(providers); - // The snapshot includes instances enabled by any project; machine settings alone do not. - const entries = settings - ? derived.map((entry) => applyProviderInstanceBranding(entry, settings)) - : derived; + const entries = settings ? brandProviderInstanceEntries(derived, settings) : derived; byEnvironment.set( environmentId, new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)), @@ -105,6 +102,14 @@ export function deriveProviderEntriesByEnvironment( return byEnvironment; } +// The snapshot includes instances enabled by any project; machine settings alone do not. +export function brandProviderInstanceEntries( + entries: ReadonlyArray, + settings: Pick, +): ReadonlyArray { + return entries.map((entry) => applyProviderInstanceBranding(entry, settings)); +} + /** projectId applies project overrides; explicit null uses machine settings. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts index 27129f11128b3..4ac36acedf761 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts @@ -62,6 +62,11 @@ describe("shouldShowInstanceBadge", () => { expect(shouldShowInstanceBadge(entry, [entry])).toBe(true); }); + it("ignores a non-hex accent color", () => { + const entry = { driverKind: codex, accentColor: "blue", enabled: true }; + expect(shouldShowInstanceBadge(entry, [entry])).toBe(false); + }); + it("shows the badge when two entries share a driver, even without an accent", () => { const first = { driverKind: codex, accentColor: undefined, enabled: true }; const second = { driverKind: codex, accentColor: undefined, enabled: true }; diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.ts b/packages/client-runtime/src/state/providerInstanceDisplay.ts index 62f65445e8217..ad69b7275342b 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.ts @@ -74,7 +74,7 @@ export function shouldShowInstanceBadge( readonly enabled: boolean; }>, ): boolean { - if (entry.accentColor) return true; + if (normalizeProviderAccentColor(entry.accentColor)) return true; let sharedProviderCount = 0; for (const candidate of entries) { if (candidate.driverKind !== entry.driverKind) continue; From 70b1ed4ca99a0a7230474b4fc40673332865343b Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:48:36 -0600 Subject: [PATCH 05/25] feat: show model and effort switch history Adapted from upstream c65f8d552b; preserve queued selection when promoting a message. (cherry picked from commit 737f9e59155f36e302cc3c9643753afdefbc9ddb) --- .../src/features/threads/ThreadFeed.tsx | 16 ++- .../threads/thread-context-divider.tsx | 6 +- .../features/threads/thread-handoff-row.tsx | 35 +++++- apps/mobile/src/lib/filePreview.test.ts | 2 +- .../src/orchestration-v2/Orchestrator.ts | 18 ++- .../SelectionRestart.integration.test.ts | 11 ++ .../SteeringCompletion.integration.test.ts | 91 +++++++++++++++ .../src/orchestration-v2/modelSwitch.test.ts | 107 ++++++++++++++++++ .../src/orchestration-v2/modelSwitch.ts | 42 +++++++ .../chat/TimelineUserMessageRow.tsx | 15 +++ .../src/components/chat/V2LifecycleRow.tsx | 27 +++++ packages/client-runtime/src/handoff.test.ts | 96 ++++++++++++++++ packages/client-runtime/src/handoff.ts | 58 ++++++++++ packages/contracts/src/orchestrationV2.ts | 9 ++ .../provider-core/src/server/notification.ts | 1 + 15 files changed, 527 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/orchestration-v2/modelSwitch.test.ts create mode 100644 apps/server/src/orchestration-v2/modelSwitch.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b1292b37a3383..0e9b4a9744339 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -15,7 +15,11 @@ import { enrichedSkillReference, } from "../../lib/enrichedLinkPresentation"; import { ThreadContextDivider } from "./thread-context-divider"; -import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row"; +import { + ThreadHandoffDivider, + ThreadHandoffRow, + ThreadModelSwitchDivider, +} from "./thread-handoff-row"; import { GuestBubbleTint, GuestMessageName } from "./guestMessageStyle"; import { ThreadGuestPendingMessage, useThreadGuestShare } from "./ThreadGuestShareBar"; import { LinkRequestCard } from "./LinkRequestCard"; @@ -3513,6 +3517,16 @@ export const ThreadFeed = memo(function ThreadFeed({ workspaceRoot={props.workspaceRoot} > + {info.item.type === "message" && + !isCompactionCommandFeedEntry(info.item) && + info.item.message.projectedItem?.item.type === "user_message" && + info.item.message.projectedItem.item.modelSwitch ? ( + + ) : null} {renderFeedEntry(info, { userMessageGrouping: info.item.userMessageGrouping, environmentId: props.environmentId, diff --git a/apps/mobile/src/features/threads/thread-context-divider.tsx b/apps/mobile/src/features/threads/thread-context-divider.tsx index 082f655d11955..19c99f6e63e45 100644 --- a/apps/mobile/src/features/threads/thread-context-divider.tsx +++ b/apps/mobile/src/features/threads/thread-context-divider.tsx @@ -6,7 +6,7 @@ import { ShimmeringWorkContent } from "./thread-work-log"; export function ThreadContextDivider(props: { label: string; - icon: AppSymbolName; + icon?: AppSymbolName; iconColor: ColorValue; active?: boolean; failed?: boolean; @@ -16,7 +16,9 @@ export function ThreadContextDivider(props: { - + {props.icon ? ( + + ) : null} {props.active ? ( { + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === selection.model); + const modelName = + (model ? resolveModelShortName(model, provider?.driver) : undefined) ?? + model?.name ?? + selection.model; + return [change.modelChanged ? modelName : null, effort].filter(Boolean).join(" "); + }; + const { from, to } = props.modelSwitch; + return ( + + ); +} + export function ThreadHandoffRow(props: { environmentId: EnvironmentId; projectedItem: OrchestrationV2ProjectedTurnItem; diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 97a5051577026..5187cd2c75adb 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -268,7 +268,7 @@ vi.mock("./openExternalUrl", () => ({})); vi.mock("./attachmentDownload", () => ({})); vi.mock("./uuid", () => ({})); vi.mock("../features/threads/thread-context-divider", () => ({})); -vi.mock("../features/threads/thread-handoff-row", () => ({})); +vi.mock("../features/threads/thread-handoff-row", () => ({ ThreadModelSwitchDivider: () => null })); vi.mock("../features/threads/ThreadGuestShareBar", () => ({ useThreadGuestShare: () => null })); vi.mock("../features/threads/SecretRequestCard", () => ({})); vi.mock("../features/threads/LinkRequestCard", () => ({ LinkRequestCard: () => null })); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1777d44eea66b..ff63d581ef72e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -48,6 +48,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2RemoteTaskChild, + type OrchestrationV2ModelSwitch, type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2ThreadShell, @@ -73,6 +74,7 @@ import { TrimmedNonEmptyString, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { modelSwitchSince } from "./modelSwitch.ts"; import { recordThreadOpen } from "@t3tools/shared/paletteSearch"; import { derivePendingBackgroundWork, @@ -2197,6 +2199,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio startedAt: now, completedAt: now, updatedAt: now, + ...(activeHandoff === null ? modelSwitchSince(projection, queuedRun.modelSelection) : {}), }; const handoffTurnItem: OrchestrationV2TurnItem | null = activeHandoff === null @@ -4943,6 +4946,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly providerTurnId: typeof providerTurn.id | null; readonly providerThreadId: OrchestrationV2ProviderThread["id"]; readonly providerInstanceId: ProviderInstanceId; + readonly modelSwitch?: OrchestrationV2ModelSwitch | undefined; }) { const message: OrchestrationV2ConversationMessage = { createdBy: input.createdBy, @@ -4998,6 +5002,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: input.text, attachments: input.attachments, ...(input.context ? { context: input.context } : {}), + ...(messageInput.modelSwitch === undefined + ? {} + : { modelSwitch: messageInput.modelSwitch }), }; yield* emitEvent({ type: "message.updated", @@ -5458,6 +5465,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerTurnId: null, providerThreadId: restartProviderThread.id, providerInstanceId: input.modelSelection.instanceId, + // Measured on the pre-restart projection, so the rewritten run is not its own baseline. + ...(restartHandoff === null ? modelSwitchSince(input.projection, input.modelSelection) : {}), }); const interruptedAttemptId = targetRun.activeAttemptId; if (interruptedAttemptId === null) { @@ -6571,6 +6580,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: dispatchText, ...(command.context ? { context: command.context } : {}), attachments: command.attachments, + ...(run.contextHandoffId === null ? modelSwitchSince(projection, modelSelection) : {}), }; const preparationTurnItem: OrchestrationV2TurnItem | null = dispatchMode.type === "defer_start" @@ -7322,6 +7332,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: dispatchText, ...(command.context ? { context: command.context } : {}), attachments: command.attachments, + ...(run.contextHandoffId === null + ? modelSwitchSince(projection, modelSelection, sourceRun?.modelSelection) + : {}), }; const activeHandoff = portableForkHandoff ?? mergeBackHandoff ?? providerSwitchHandoff; const handoffSourceRuns = @@ -9493,9 +9506,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "runtimeRequests", "subagents", ], - { turnItemTypes: [], messageRoles: ["user"] }, + { turnItemTypes: ["user_message"], messageRoles: ["user"] }, ) .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId }))); + if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -9664,7 +9678,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, effects, projection, - modelSelection: projection.thread.modelSelection, + modelSelection: queuedRun.modelSelection, targetRunId: command.targetRunId, messageId: queuedMessage.id, text: queuedMessage.text, diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index d059c4c20e9d7..4352d8b2d6477 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -5,6 +5,7 @@ import { MessageId, type ModelSelection, type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ThreadProjection, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, ProjectId, @@ -47,6 +48,10 @@ const seedSelection = { instanceId: providerInstanceId, model: "seed-model", } satisfies ModelSelection; +const modelSwitches = (projection: OrchestrationV2ThreadProjection) => + projection.turnItems.flatMap((item) => + item.type === "user_message" && item.modelSwitch !== undefined ? [item.modelSwitch] : [], + ); const pooledCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const exclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexProviderCapabilitiesV2, @@ -407,6 +412,9 @@ it.live("retries a failed selection restart session open before completing", () "selection restart supersede must not project hard-Stop interrupt items", ); assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model); + assert.deepEqual(modelSwitches(projection), [ + { from: initialSelection, to: replacementSelection }, + ]); assert.equal(projection.runs.at(-1)?.status, "completed"); assert.isTrue(captured.failedReplacementOpen); assert.equal(captured.closedSessionCount, 0); @@ -690,6 +698,9 @@ it.live( const { projection, captured } = result; assert.lengthOf(projection.runs, 2); assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); + assert.deepEqual(modelSwitches(projection), [ + { from: seedSelection, to: replacementSelection }, + ]); assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); assert.equal(captured.closedSessionCount, 1); assert.deepEqual( diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index d4867a264ec9c..d4c6235d23015 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1132,6 +1132,97 @@ it.effect("keeps a held commentary draft before the steer when turn end flushes ), ); +it.effect("steers a promoted queued message with the selection it was queued with", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-queued-selection", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued"), + threadId, + messageId: MessageId.make("message:queued"), + text: "queued", + attachments: [], + modelSelection: composerSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["queued"]); + assert.deepEqual(projection.thread.modelSelection, composerSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("marks the model switch on a promoted message that restarts the turn", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-restart-switch", + true, + false, + ); + const otherSelection = { ...runSelection, model: "other-model" }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued"), + threadId, + messageId: MessageId.make("message:queued"), + text: "queued", + attachments: [], + modelSelection: otherSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const promoted = (yield* orchestrator.getThreadProjection(threadId)).turnItems.find( + (item) => item.type === "user_message" && item.messageId === "message:queued", + ); + assert.deepEqual(promoted?.type === "user_message" ? promoted.modelSwitch : undefined, { + from: runSelection, + to: otherSelection, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("keeps a queued message queued when promoted into a run that Stop reached", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/modelSwitch.test.ts b/apps/server/src/orchestration-v2/modelSwitch.test.ts new file mode 100644 index 0000000000000..940340074c1f2 --- /dev/null +++ b/apps/server/src/orchestration-v2/modelSwitch.test.ts @@ -0,0 +1,107 @@ +import { + ContextHandoffId, + MessageId, + ProviderInstanceId, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { modelSwitchSince } from "./modelSwitch.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const selection = (model: string) => ({ instanceId, model }); +const run = ( + message: string, + ordinal: number, + model: string, + status: OrchestrationV2ThreadProjection["runs"][number]["status"] = "completed", +) => + ({ + ordinal, + status, + completedAt: null, + contextHandoffId: null, + userMessageId: MessageId.make(message), + modelSelection: selection(model), + }) as OrchestrationV2ThreadProjection["runs"][number]; +const shown = (...messages: ReadonlyArray) => + messages.map((message) => ({ + type: "user_message", + messageId: MessageId.make(message), + inputIntent: "turn_start", + status: "completed", + text: message, + attachments: [], + })) as unknown as OrchestrationV2ThreadProjection["turnItems"]; + +describe("modelSwitchSince", () => { + it("measures from the latest run whose message is in the timeline", () => { + // B failed before it started; its message shows "a -> b", so the next one must show "b -> a". + expect( + modelSwitchSince( + { + runs: [ + run("first", 1, "a"), + run("failed", 2, "b", "failed"), + run("queued", 3, "c", "queued"), + run("undone", 4, "d", "rolled_back"), + ], + turnItems: shown("first", "failed", "queued", "undone"), + }, + selection("a"), + ), + ).toEqual({ modelSwitch: { from: selection("b"), to: selection("a") } }); + }); + + it("skips messages clients hide", () => { + const [first, pending, compact, answer] = shown( + "first", + "pending", + "/compact", + "async-answer:question", + ); + expect( + modelSwitchSince( + { + runs: [ + run("first", 1, "a"), + run("pending", 2, "b", "cancelled"), + run("/compact", 3, "c"), + run("async-answer:question", 4, "d"), + ], + turnItems: [ + first!, + { ...pending!, inputIntent: "queued_turn", status: "pending" }, + compact!, + answer!, + ] as OrchestrationV2ThreadProjection["turnItems"], + }, + selection("c"), + ), + ).toEqual({ modelSwitch: { from: selection("a"), to: selection("c") } }); + }); + + it("counts a wake whose handoff row shows the swap", () => { + const wake = { + ...run("wake", 2, "other"), + contextHandoffId: ContextHandoffId.make("handoff"), + }; + expect( + modelSwitchSince( + { runs: [run("first", 1, "a"), wake], turnItems: shown("first") }, + selection("other"), + ), + ).toEqual({}); + }); + + it("records nothing for a first run or an unchanged selection", () => { + expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"))).toEqual({}); + // A fork's first message compares with the run it forked from. + expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"), selection("b"))).toEqual({ + modelSwitch: { from: selection("b"), to: selection("a") }, + }); + expect( + modelSwitchSince({ runs: [run("first", 1, "a")], turnItems: shown("first") }, selection("a")), + ).toEqual({}); + }); +}); diff --git a/apps/server/src/orchestration-v2/modelSwitch.ts b/apps/server/src/orchestration-v2/modelSwitch.ts new file mode 100644 index 0000000000000..5b893cd0026d2 --- /dev/null +++ b/apps/server/src/orchestration-v2/modelSwitch.ts @@ -0,0 +1,42 @@ +import { + isCompactionCommandMessage, + type ModelSelection, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; + +/** + * The model or effort change a new run brings, measured from the latest run the timeline shows + * through its message or its handoff row, so consecutive dividers always chain. `baseline` stands + * in when there is none, such as a fork's source run. Callers skip starts with a context handoff; + * the handoff item already shows those. + */ +export function modelSwitchSince( + projection: Pick, + to: ModelSelection, + baseline?: ModelSelection, +): Pick, "modelSwitch"> { + // Clients hide pending queued messages, fold async answers into their question, and replace + // `/compact` with a compaction row. + const shown = new Set( + projection.turnItems.flatMap((item) => + item.type === "user_message" && + !(item.inputIntent === "queued_turn" && item.status === "pending") && + !item.messageId.startsWith("async-answer:") && + !isCompactionCommandMessage({ role: "user", ...item }) + ? [item.messageId] + : [], + ), + ); + let previous: OrchestrationV2ThreadProjection["runs"][number] | undefined; + for (const run of projection.runs) { + if (run.status === "rolled_back" || run.status === "queued") continue; + // A wake shows as a notification, but its context handoff row still shows the swap. + if (!shown.has(run.userMessageId) && run.contextHandoffId === null) continue; + if (previous === undefined || runRanAfter(run, previous)) previous = run; + } + const from = previous?.modelSelection ?? baseline; + return from === undefined || modelSelectionsEqual(from, to) ? {} : { modelSwitch: { from, to } }; +} diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index e73f5d1d1e9a7..e4389b4fee97b 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -38,6 +38,7 @@ import { } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; +import { ModelSwitchRow } from "./V2LifecycleRow"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { AttachmentImage } from "../media/AttachmentImage"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; @@ -245,6 +246,20 @@ function UserVideoAttachment({ } export function UserMessageRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const item = row.projectedItem?.item; + const modelSwitch = item?.type === "user_message" ? item.modelSwitch : undefined; + return ( + <> + {modelSwitch ? ( + + ) : null} + + + ); +} + +function UserMessageContent({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); return ctx.workflowOpening?.messageId === row.message.id ? ( ; } +/** A same-harness model or effort change. Shows only what changed. */ +export function ModelSwitchRow(props: { + readonly environmentId: EnvironmentId; + readonly modelSwitch: OrchestrationV2ModelSwitch; +}) { + const providers = + useAtomValue(serverEnvironment.providersValueAtom(props.environmentId)) ?? + EMPTY_SERVER_PROVIDERS; + const change = resolveModelSwitchChange(props.modelSwitch, providers); + if (change === null) return null; + const describe = (selection: ModelSelection, effort: string | undefined) => { + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === selection.model); + const modelName = model ? getTriggerDisplayModelName(model, provider?.driver) : selection.model; + return [change.modelChanged ? modelName : null, effort].filter(Boolean).join(" "); + }; + const { from, to } = props.modelSwitch; + return ( + + ); +} + export function HandoffRow(props: { readonly environmentId: EnvironmentId; readonly endpoints: HandoffEndpoints; diff --git a/packages/client-runtime/src/handoff.test.ts b/packages/client-runtime/src/handoff.test.ts index 508a2f5694d02..c1e26e4693f2d 100644 --- a/packages/client-runtime/src/handoff.test.ts +++ b/packages/client-runtime/src/handoff.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { resolveHandoffEndpoints, + resolveModelSwitchChange, resolvePendingHandoff, type HandoffTimelineRun, } from "./handoff.ts"; @@ -173,3 +174,98 @@ describe("pending context handoff", () => { ).toBeNull(); }); }); + +describe("model switch change", () => { + const selection = (model: string, options?: Record) => ({ + instanceId: from, + model, + ...(options === undefined + ? {} + : { options: Object.entries(options).map(([id, value]) => ({ id, value })) }), + }); + const providers = [ + { + instanceId: from, + models: ["gpt", "mini"].map((slug) => ({ + slug, + capabilities: { + optionDescriptors: [ + { id: "serviceTier", type: "select", options: [{ id: "fast", label: "Fast" }] }, + { + id: "reasoningEffort", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + ], + }, + })), + }, + ] as unknown as ServerProvider[]; + + it("shows only what changed, reading an unset effort as the default", () => { + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "high" }) }, + providers, + ), + ).toEqual({ modelChanged: false, effort: { from: "Medium", to: "High" } }); + expect( + resolveModelSwitchChange({ from: selection("gpt"), to: selection("mini") }, providers), + ).toEqual({ modelChanged: true, effort: null }); + }); + + it("ignores changes the user cannot see", () => { + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "medium" }) }, + providers, + ), + ).toBeNull(); + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { serviceTier: "fast" }) }, + providers, + ), + ).toBeNull(); + // A cataloged model without an effort control ignores stale effort options. + expect( + resolveModelSwitchChange( + { + from: selection("plain", { reasoningEffort: "low" }), + to: selection("plain", { reasoningEffort: "high" }), + }, + [{ instanceId: from, models: [{ slug: "plain" }] }] as unknown as ServerProvider[], + ), + ).toBeNull(); + }); + + it("falls back to stated efforts without a catalog", () => { + expect( + resolveModelSwitchChange( + { + from: selection("gpt", { reasoningEffort: "low" }), + to: selection("gpt", { reasoningEffort: "high" }), + }, + [], + ), + ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } }); + expect( + resolveModelSwitchChange( + { + from: selection("pi", { thinking: "low" }), + to: selection("pi", { thinking: "high" }), + }, + [], + ), + ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } }); + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "high" }) }, + [], + ), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index a26c018ad9cdf..95ca64608d503 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -1,12 +1,20 @@ import type { ModelSelection, + OrchestrationV2ModelSwitch, OrchestrationV2Run, OrchestrationV2TurnItem, ProviderInstanceId, ServerProvider, + ServerProviderModel, } from "@t3tools/contracts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, +} from "@t3tools/shared/model"; +import { REASONING_SELECT_DESCRIPTOR_IDS } from "./modelTraits.ts"; import type { ThreadStructure } from "./state/models.ts"; +import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts"; export type HandoffTimelineRun = Pick< OrchestrationV2Run, @@ -80,3 +88,53 @@ function latestRunModelBefore( } return latest?.modelSelection.model; } + +export interface ModelSwitchChange { + readonly modelChanged: boolean; + /** Effort labels, set only when the effort changed. */ + readonly effort: { readonly from: string; readonly to: string } | null; +} + +/** + * What a recorded model switch visibly changed, or null when it changed neither the model nor the + * effort. The composer omits an untouched effort, so an unset effort reads as the model's default. + */ +export function resolveModelSwitchChange( + { from, to }: OrchestrationV2ModelSwitch, + providers: ReadonlyArray, +): ModelSwitchChange | null { + const modelsOf = (selection: ModelSelection) => + providers.find((provider) => provider.instanceId === selection.instanceId)?.models; + const fromEffort = resolveEffort(from, modelsOf(from)); + const toEffort = resolveEffort(to, modelsOf(to)); + const modelChanged = from.model !== to.model; + const effort = + fromEffort && toEffort && fromEffort.value !== toEffort.value + ? { from: fromEffort.label, to: toEffort.label } + : null; + return modelChanged || effort ? { modelChanged, effort } : null; +} + +/** The composer's reasoning control for the model; stated values only when the catalog lacks it. */ +function resolveEffort( + selection: ModelSelection, + models: ReadonlyArray | undefined, +): { readonly value: string; readonly label: string } | undefined { + const model = models?.find((candidate) => candidate.slug === selection.model); + if (model === undefined) { + // Pi states its effort as `thinking`; the catalog would otherwise mark it a reasoning control. + const value = [...REASONING_EFFORT_OPTION_IDS, "thinking"] + .map((id) => getModelSelectionStringOptionValue(selection, id)) + .find((candidate) => candidate !== undefined); + return value === undefined ? undefined : { value, label: value }; + } + const descriptor = model.capabilities?.optionDescriptors?.find( + (option) => option.type === "select" && REASONING_SELECT_DESCRIPTOR_IDS.has(option.id), + ); + if (descriptor === undefined || descriptor.type !== "select") return undefined; + const value = + getModelSelectionStringOptionValue(selection, descriptor.id) ?? + getProviderOptionCurrentValue(descriptor, selection); + if (typeof value !== "string") return undefined; + return { value, label: descriptor.options.find((option) => option.id === value)?.label ?? value }; +} diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f732a7cc1ba4b..b704de0ddc714 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1484,6 +1484,13 @@ export const OrchestrationV2UserMessageInputIntent = Schema.Literals([ export type OrchestrationV2UserMessageInputIntent = typeof OrchestrationV2UserMessageInputIntent.Type; +/** A same-harness model or effort change that took effect with this message. */ +export const OrchestrationV2ModelSwitch = Schema.Struct({ + from: ModelSelection, + to: ModelSelection, +}); +export type OrchestrationV2ModelSwitch = typeof OrchestrationV2ModelSwitch.Type; + const OrchestrationV2TurnItemBaseFields = { /** Wire-only cursor for collapsed work; persisted items keep their complete history. */ deferredWork: Schema.optionalKey( @@ -1605,6 +1612,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ textOmitted: DedupedBodyMarker, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), + modelSwitch: Schema.optional(OrchestrationV2ModelSwitch), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -2438,6 +2446,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ text: Schema.String, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), + modelSwitch: Schema.optional(OrchestrationV2ModelSwitch), }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, diff --git a/packages/provider-core/src/server/notification.ts b/packages/provider-core/src/server/notification.ts index 30f6826278c7a..85cac547cfa37 100644 --- a/packages/provider-core/src/server/notification.ts +++ b/packages/provider-core/src/server/notification.ts @@ -217,6 +217,7 @@ export function notificationTurnItem( createdBy: _createdBy, creationSource: _creationSource, scheduledTaskId: _scheduledTaskId, + modelSwitch: _modelSwitch, ...base } = item; return { ...base, type: "notification", ...notification }; From 497adeb0aaf1693dc00982385571ed95bbbd55a4 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:50:09 -0600 Subject: [PATCH 06/25] feat: filter usage by provider and model Adapted from upstream e6e2ab05cc. (cherry picked from commit b5d09a2af6fe30a2a6d76088c31b35fe6493aca3) --- apps/mobile/src/state/usage.ts | 16 ++- apps/server/src/usage/UsageService.test.ts | 114 +++++++++++++++++- apps/server/src/usage/UsageService.ts | 12 +- apps/server/src/usage/usageAggregation.ts | 36 ++++++ apps/web/src/state/usage.ts | 16 ++- docs/user/usage.md | 24 ++++ packages/client-runtime/src/state/server.ts | 8 ++ .../client-runtime/src/state/usage.test.ts | 32 ++++- packages/client-runtime/src/state/usage.ts | 16 ++- packages/contracts/src/settings.test.ts | 11 ++ packages/contracts/src/settings.ts | 4 + packages/contracts/src/usage.ts | 20 +++ packages/shared/src/usageMerge.test.ts | 8 ++ packages/shared/src/usageMerge.ts | 5 +- 14 files changed, 307 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index 9851b841c88c5..ed9fb01fc1f7f 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -6,7 +6,11 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { + needsCursorKeychainAccess, + refreshUsage, + usageSummaryInput, +} from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -52,7 +56,15 @@ const usageByWindowAtom = Atom.family((windowKey: string) => }); continue; } - const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const result = get( + serverEnvironment.usageSummary({ + environmentId, + input: usageSummaryInput( + input, + get(serverEnvironment.usageModelFilterAtom(environmentId)), + ), + }), + ); const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index aaee756cb97b1..63b4612f5ac2f 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -67,12 +67,17 @@ const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unkno const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); -function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { +function claudeLine( + id: number, + outputTokens: number, + model = "claude-fable-5", + sessionId = "session-1", +): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", requestId: `req_${id}`, - sessionId: "session-1", + sessionId, message: { id: `msg_${id}`, model, @@ -2065,6 +2070,111 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("filters original model IDs before aliases, pricing, buckets and session counts", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile( + transcript, + claudeLine(1, 3, "orbit-1", "mixed") + + claudeLine(2, 5, "orbit-2", "mixed") + + claudeLine(3, 7, "drift-9", "mixed") + + claudeLine(4, 11, "drift-9", "excluded-only"), + ), + ); + const rates = { + "orbit-1": { input_cost_per_token: 0.000001, output_cost_per_token: 0.00001 }, + "orbit-2": { input_cost_per_token: 0.000002, output_cost_per_token: 0.00002 }, + }; + const include = { + // The codex selector must not match the Claude drift-9 records. + include: [ + { provider: "claude", model: "orbit-1" }, + { model: "orbit-2" }, + { provider: "codex", model: "drift-9" }, + ], + }; + const exclude = { exclude: [{ model: "drift-9" }] }; + const HOURLY: UsageSummaryInput = { + ...WINDOW, + resolution: "hour", + sinceTime: "2026-08-01T09:00:00Z", + untilTime: "2026-08-01T11:00:00Z", + }; + const cells = (summary: UsageSummary) => + summary.buckets.map(({ model, totals, costUsd, records, sessions }) => ({ + model, + outputTokens: totals.outputTokens, + costUsd: Math.round(costUsd * 1e6), + records, + sessions, + })); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + // Same service, concurrent reads: the in-flight key must keep these scans apart. + const [unfiltered, noOp, keepNothing, included, reordered, excluded, hourly] = + yield* Effect.all( + [ + service.readSummary(WINDOW), + service.readSummary({ ...WINDOW, modelFilter: { exclude: [] } }), + service.readSummary({ ...WINDOW, modelFilter: { include: [] } }), + service.readSummary({ ...WINDOW, modelFilter: include }), + service.readSummary({ + ...WINDOW, + modelFilter: { include: [...include.include].reverse() }, + }), + service.readSummary({ ...WINDOW, modelFilter: exclude }), + service.readSummary({ ...HOURLY, modelFilter: include }), + ], + { concurrency: "unbounded" }, + ); + + // Unfiltered keeps today's behavior: the alias folds drift-9 into orbit-1. + assert.deepStrictEqual(cells(unfiltered), [ + { model: "orbit-1", outputTokens: 21, costUsd: 240, records: 3, sessions: 2 }, + { model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 }, + ]); + assert.strictEqual(unfiltered.sources[0]?.distinctSessions, 2); + assert.strictEqual(unfiltered.modelFilter, undefined); + assert.strictEqual(noOp, unfiltered); + assert.deepStrictEqual(keepNothing.buckets, []); + assert.strictEqual(keepNothing.sources[0]?.distinctSessions, 0); + + const kept = [ + { model: "orbit-1", outputTokens: 3, costUsd: 40, records: 1, sessions: 1 }, + { model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 }, + ]; + assert.deepStrictEqual(cells(included), kept); + assert.deepStrictEqual(cells(excluded), kept); + assert.deepStrictEqual(cells(hourly), kept); + assert.strictEqual(hourly.buckets[0]?.hourStart, "2026-08-01T10:00:00.000Z"); + for (const summary of [included, excluded, hourly]) { + assert.strictEqual(summary.sources[0]?.distinctSessions, 1); + } + // Equal filters share one scan and echo one canonical form. + assert.strictEqual(reordered, included); + assert.deepStrictEqual(included.modelFilter, { + include: [ + { provider: "claude", model: "orbit-1" }, + { provider: "codex", model: "drift-9" }, + { model: "orbit-2" }, + ], + }); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-service-model-filter-test", + home, + settings: { ...settings, usageModelAliases: { "drift-9": "orbit-1" } }, + ratesDocument: rates, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index beb6d2e6071be..81114f244f670 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -39,7 +39,7 @@ import type { TranscriptUsageFormat, UsageRecord, } from "@t3tools/provider-core/server/usage"; -import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; +import { canonicalModelFilter, resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -752,6 +752,7 @@ export const make = Effect.gen(function* () { rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), modelAliases: resolveModelAliases(settings.usageModelAliases), + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), }); const sources: UsageSource[] = []; @@ -922,6 +923,7 @@ export const make = Effect.gen(function* () { sources, pricing: pricing(), scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), } satisfies UsageSummary; }); @@ -937,9 +939,15 @@ export const make = Effect.gen(function* () { input.untilTime ?? null, settings, input.awaitRefresh === true, + input.modelFilter ?? null, ]); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const readSummary = Effect.fn("UsageService.readSummary")(function* ( + requested: UsageSummaryInput, + ) { + const { modelFilter: requestedFilter, ...unfiltered } = requested; + const modelFilter = canonicalModelFilter(requestedFilter); + const input = modelFilter === undefined ? unfiltered : { ...unfiltered, modelFilter }; const settings = yield* readSettings; const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 17903cd062eae..8f94c84a3c470 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -3,6 +3,8 @@ import type { UsageBucket, UsageCategoryCost, UsageDay, + UsageModelFilter, + UsageModelSelector, UsageResolution, UsageTokenTotals, } from "@t3tools/contracts"; @@ -67,6 +69,7 @@ export interface AggregateOptions { readonly rates: RateTable; readonly priceOverrides?: RateTable; readonly modelAliases?: ReadonlyMap; + readonly modelFilter?: UsageModelFilter; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; @@ -88,6 +91,38 @@ export function resolveModelAliases( return resolved; } +/** Matches original IDs, so an alias cannot pull excluded usage into a kept model. */ +function keepsUsageRecord(filter: UsageModelFilter | undefined, record: UsageRecord): boolean { + if (filter === undefined) return true; + const model = record.model.trim(); + const matches = (selector: UsageModelSelector) => + selector.model === model && + (selector.provider === undefined || selector.provider === record.provider); + return (filter.include?.some(matches) ?? true) && !(filter.exclude?.some(matches) ?? false); +} + +const selectorKey = (selector: UsageModelSelector) => + JSON.stringify([selector.provider ?? null, selector.model]); + +const canonicalSelectors = (selectors: readonly UsageModelSelector[]) => + [...new Map(selectors.map((selector) => [selectorKey(selector), selector]))] + // Code-unit order, so servers in different locales echo the same form. + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([, selector]) => selector); + +/** Equal filters get one form, so they share scans and clients can compare echoed filters. */ +export function canonicalModelFilter( + filter: UsageModelFilter | undefined, +): UsageModelFilter | undefined { + if (filter === undefined) return undefined; + const exclude = filter.exclude?.length ? canonicalSelectors(filter.exclude) : undefined; + if (filter.include === undefined && exclude === undefined) return undefined; + return { + ...(filter.include === undefined ? {} : { include: canonicalSelectors(filter.include) }), + ...(exclude === undefined ? {} : { exclude }), + }; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; readonly duplicatesDropped: number; @@ -129,6 +164,7 @@ export class UsageAggregator { } add(input: UsageRecord, sourcePath?: string): boolean { + if (!keepsUsageRecord(this.#options.modelFilter, input)) return false; const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 9a68a5fa724f7..f47a65cd08268 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -8,7 +8,11 @@ import { type UsageProviderKind, type UsageSummaryInput, } from "@t3tools/contracts"; -import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { + needsCursorKeychainAccess, + refreshUsage, + usageSummaryInput, +} from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -55,7 +59,15 @@ const usageByWindowAtom = Atom.family((windowKey: string) => }); continue; } - const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const result = get( + serverEnvironment.usageSummary({ + environmentId, + input: usageSummaryInput( + input, + get(serverEnvironment.usageModelFilterAtom(environmentId)), + ), + }), + ); const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, diff --git a/docs/user/usage.md b/docs/user/usage.md index 4094757f16cb9..f0ce2bc8aca4c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -87,6 +87,30 @@ marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish without writing again to environments that already saved. Changes are not queued after you close the dialog. +## Show only some models + +To count only some models, or to leave some out, add `usageModelFilter` to the server's +`settings.json` in its `userdata` directory, such as `~/.t3/userdata/settings.json`: + +```json +{ + "usageModelFilter": { + "include": [{ "provider": "claude", "model": "orbit-1" }, { "model": "orbit-2" }], + "exclude": [{ "model": "drift-9" }] + } +} +``` + +`include` keeps only the listed models and `exclude` leaves the listed models out. `provider` is +one of `claude`, `codex`, `cursor`, `grok`, `opencode`, `antigravity`, `pi`, or `muse`; omit it +to match a model ID from any provider. Use the model ID that Usage shows without **Map to**, such +as a provider-prefixed ID for Pi. Filters apply before **Map to**, so a model you leave out is not +counted under its mapped name. Usage applies each environment's own filter. Remove the setting to +show every model again. + +If the setting is invalid, Usage ignores it and shows every model, and the next save of any other +setting removes it from `settings.json`. + ## Track subscription limits **Usage → Limits** shows a named row for every enabled provider connection on the selected diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 9cddc35d9af25..45bc5276435b3 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1112,6 +1112,13 @@ export function createServerEnvironmentAtoms( ]); }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), ); + // Settings snapshots rebuild the filter object; only a changed filter should touch the Usage tab. + const usageModelFilterAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(settingsValueAtom(environmentId))?.usageModelFilter).pipe( + Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b)), + Atom.withLabel(`environment-data:server:usage-model-filter:${environmentId}`), + ), + ); const providersValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( Atom.withLabel(`environment-data:server:providers:${environmentId}`), @@ -1207,6 +1214,7 @@ export function createServerEnvironmentAtoms( }), updateStateAtom, settingsValueAtom, + usageModelFilterAtom, providersValueAtom, providerAuthState: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:provider:auth-state", diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 29aac2ae98ecf..0650b1f2093c7 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -5,7 +5,9 @@ import { UsageDay, USAGE_CONTRACT_VERSION, type ServerProvider, + type UsageModelFilter, type UsageSummary, + type UsageSummaryInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -39,7 +41,7 @@ afterEach(() => { for (const registry of registries.splice(0)) registry.dispose(); }); -function harness(ids = ["a"]) { +function harness(ids = ["a"], modelFilters: Record = {}) { const registry = AtomRegistry.make(); registries.push(registry); const environments = ids.map((id) => { @@ -65,13 +67,18 @@ function harness(ids = ["a"]) { if (!environment) throw new Error(`Unknown environment: ${environmentId}`); return environment; } + const requested: { environmentId: EnvironmentId; input: UsageSummaryInput }[] = []; const options = { registry, environmentIds: environments.map((entry) => entry.environmentId), input, server: { - usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) => - get(environmentId).query, + usageSummary: (target: { environmentId: EnvironmentId; input: UsageSummaryInput }) => { + requested.push(target); + return get(target.environmentId).query; + }, + usageModelFilterAtom: (environmentId: EnvironmentId) => + Atom.make(modelFilters[environmentId]), refreshUsageRates: { label: "test:rates", requiredScopes: () => [], @@ -86,7 +93,7 @@ function harness(ids = ["a"]) { presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation, }, } satisfies Parameters[0]; - return { registry, environments, refresh: () => refreshUsage(options) }; + return { registry, environments, requested, refresh: () => refreshUsage(options) }; } describe("manual usage refresh", () => { @@ -113,6 +120,23 @@ describe("manual usage refresh", () => { expect(finished).toBe(true); }); + it("rescans each environment with its own configured model filter", async () => { + const modelFilter = { include: [{ provider: "claude" as const, model: "orbit-1" }] }; + const { environments, requested, refresh } = harness(["filtered", "plain"], { + filtered: modelFilter, + }); + const refreshing = refresh(); + for (const entry of environments) { + entry.rates.resolve(AsyncResult.success(pricing)); + entry.scan.resolve(summary); + } + await refreshing; + expect(requested).toEqual([ + { environmentId: "filtered", input: { ...input, modelFilter } }, + { environmentId: "plain", input }, + ]); + }); + it("settles when an environment disconnects during the rescan", async () => { const { registry, diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 7bc582eb7da39..2dea725be1f14 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, ServerProvider, + UsageModelFilter, UsageSummary, UsageSummaryInput, } from "@t3tools/contracts"; @@ -43,6 +44,14 @@ export function cursorKeychainAccessEnvironments< : environments.filter((environment) => environment.needsCursorKeychainAccess); } +/** The Usage tab asks each environment with that environment's configured model filter. */ +export function usageSummaryInput( + input: UsageSummaryInput, + modelFilter: UsageModelFilter | undefined, +): UsageSummaryInput { + return modelFilter === undefined ? input : { ...input, modelFilter }; +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); @@ -86,7 +95,7 @@ export async function refreshUsage({ registry: AtomRegistry.AtomRegistry; server: Pick< ReturnType, - "usageSummary" | "refreshUsageRates" + "usageSummary" | "refreshUsageRates" | "usageModelFilterAtom" >; presentations: Pick, "presentationAtom">; environmentIds: readonly EnvironmentId[]; @@ -94,7 +103,10 @@ export async function refreshUsage({ }): Promise { await Promise.all( environmentIds.map(async (environmentId) => { - const query = server.usageSummary({ environmentId, input }); + const query = server.usageSummary({ + environmentId, + input: usageSummaryInput(input, registry.get(server.usageModelFilterAtom(environmentId))), + }); const presentation = presentations.presentationAtom(environmentId); const controller = new AbortController(); const abortWhenDisconnected = () => { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 44355c4062d00..9f11642a5e188 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -139,6 +139,17 @@ describe("ServerSettings usage price overrides", () => { expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow(); } }); + + it("ignores an invalid model filter without dropping the other settings", () => { + const valid = { include: [{ provider: "newer-provider", model: "orbit-1" }] }; + expect(decodeServerSettings({ usageModelFilter: valid }).usageModelFilter).toEqual(valid); + const settings = decodeServerSettings({ + usageModelFilter: { exclude: [{ model: "" }] }, + usageModelAliases: { "drift-9": "orbit-1" }, + }); + expect(settings.usageModelFilter).toBeUndefined(); + expect(settings.usageModelAliases).toEqual({ "drift-9": "orbit-1" }); + }); }); describe("ServerSettings secret providers", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8c61597245fd4..753b1c6c7b49e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,6 +23,7 @@ import { TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; +import { UsageModelFilter } from "./usage.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; import { SettingsSecretValue } from "./settingsSecretValue.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; @@ -1396,6 +1397,9 @@ export const ServerSettings = Schema.Struct({ usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + + /** Sent by the Usage tab with each summary request to this environment; edit in settings.json. Invalid values are ignored. */ + usageModelFilter: ForwardCompatibleOptional(UsageModelFilter), }); export type ServerSettings = typeof ServerSettings.Type; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 2fd82d8fab795..28fe3dfb169df 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -111,6 +111,23 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; +/** + * Matches a provider's original model ID, before usage model aliases apply. Omit provider to + * match any; a string provider lets older clients carry selectors for providers they lack. + */ +export const UsageModelSelector = Schema.Struct({ + provider: Schema.optional(TrimmedNonEmptyString), + model: TrimmedNonEmptyString, +}); +export type UsageModelSelector = typeof UsageModelSelector.Type; + +/** Keeps records matching `include` (all when omitted) and not matching `exclude`. */ +export const UsageModelFilter = Schema.Struct({ + include: Schema.optional(Schema.Array(UsageModelSelector)), + exclude: Schema.optional(Schema.Array(UsageModelSelector)), +}); +export type UsageModelFilter = typeof UsageModelFilter.Type; + export const UsageSummaryInput = Schema.Struct({ sinceDay: UsageDay, untilDay: UsageDay, @@ -120,6 +137,7 @@ export const UsageSummaryInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), untilTime: Schema.optional(TrimmedNonEmptyString), awaitRefresh: Schema.optional(Schema.Boolean), + modelFilter: Schema.optional(UsageModelFilter), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; @@ -133,6 +151,8 @@ export const UsageSummary = Schema.Struct({ sources: ForwardCompatibleArray(UsageSource), pricing: UsagePricing, scanDurationMs: NonNegativeInt, + /** The filter the server applied; absent means unfiltered, including servers that predate filters. */ + modelFilter: Schema.optional(UsageModelFilter), }); export type UsageSummary = typeof UsageSummary.Type; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index b58c2e6a8d878..828b652077f62 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -271,6 +271,14 @@ describe("mergeUsage", () => { ); expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]); } + + const filtered = environment("new", { + ...partial.summary, + modelFilter: { exclude: [{ model: "claude-fable-5" }] }, + }); + const merged = mergeUsage([complete, filtered], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.sessions).toBe(1); }); it("retains a complete cell when a larger partial cell may have skipped old records", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 994ae01d6ffec..6c3e9e84ae21d 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -178,9 +178,12 @@ function claimSources(environments: readonly EnvironmentUsage[]): { if (source.status !== "partial") continue; const key = fingerprintKey(source.fingerprint); const owner = ownerScanByFingerprint.get(key); + // A scan under another model filter counts different records, so its cells are not missing ones. if ( owner?.source.status !== "ok" || - Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt) + Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt) || + JSON.stringify(environment.summary.modelFilter) !== + JSON.stringify(owner.environment.summary.modelFilter) ) { continue; } From 1fbcb9aab9a08de915cc5be7d95c826d6035c774 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:51:48 -0600 Subject: [PATCH 07/25] perf: bound recovery reads by thread ownership Adapted from upstream d833184115 using the existing owner index. (cherry picked from commit 7d7cd21843c4bd1fd523883bb98d954522a777aa) --- .../ProjectionRecovery.test.ts | 146 ++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 26 ++-- 2 files changed, 160 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 4df71fd5a9d63..f1169f1e910b4 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -22,6 +22,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -657,6 +658,151 @@ it.effect("includes shared sessions and provider-owned background rosters in rec }).pipe(Effect.provide(layerTest)), ); +it.effect("bounds runtime provider-thread reads by ownership and preserves recovery liveness", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const threadId = yield* createThread("ownership"); + const other = yield* createThread("other-owner"); + const ownerNodeId = NodeId.make("node:recovery:owner"); + yield* projections.apply({ + id: EventId.make("event:recovery:owner"), + type: "node.updated", + threadId, + nodeId: ownerNodeId, + driver, + occurredAt: now, + payload: { + id: ownerNodeId, + threadId, + runId: null, + parentNodeId: null, + rootNodeId: ownerNodeId, + kind: "root_turn", + status: "completed", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: now, + }, + }); + const cases = [ + { name: "direct", ownership: "direct", status: "active", ordinal: 2, included: true }, + { name: "owner", ownership: "owner", status: "active", ordinal: null, included: true }, + { name: "owner-only", ownership: "owner", status: "active", ordinal: 1, included: true }, + { name: "subagent", ownership: "subagent", status: "active", ordinal: 1, included: true }, + { name: "duplicate", ownership: "all", status: "active", ordinal: 1, included: true }, + { name: "roster", ownership: "direct", status: "idle", ordinal: 3, included: true }, + { name: "run", ownership: "owner", status: "idle", ordinal: 4, included: true }, + { name: "item", ownership: "subagent", status: "idle", ordinal: 5, included: true }, + { name: "waiting-run", ownership: "direct", status: "idle", ordinal: 6, included: true }, + { name: "settled", ownership: "all", status: "idle", ordinal: 7, included: false }, + { name: "unrelated", ownership: "none", status: "active", ordinal: 8, included: false }, + { name: "unrelated-run", ownership: "none", status: "idle", ordinal: 9, included: false }, + ] as const; + for (const entry of cases) { + const id = ProviderThreadId.make(`provider-thread:ownership:${entry.name}`); + yield* projections.apply({ + id: EventId.make(`event:${id}`), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id, + appThreadId: + entry.name === "owner-only" + ? null + : entry.ownership === "direct" || entry.ownership === "all" + ? threadId + : other, + ownerNodeId: + entry.ownership === "owner" || entry.ownership === "all" ? ownerNodeId : null, + driver, + providerInstanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: entry.status, + firstRunOrdinal: entry.ordinal, + lastRunOrdinal: entry.ordinal, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: + entry.name === "roster" + ? [{ taskId: "shell", description: "background", kind: "command" }] + : [], + }, + }); + if (entry.ownership === "subagent" || entry.ownership === "all") { + for (const suffix of ["a", "b"]) { + yield* sql` + INSERT INTO orchestration_v2_projection_subagents + (subagent_id, thread_id, parent_node_id, provider, provider_thread_id, + origin, status, updated_at, payload_json) + VALUES (${`${id}:${suffix}`}, ${threadId}, ${ownerNodeId}, 'codex', ${id}, + 'provider_native', 'completed', '', '{}') + `; + } + } + if (entry.name === "run" || entry.name === "unrelated-run") { + yield* createRun(threadId, "waiting", { ordinal: entry.ordinal, providerThreadId: id }); + } + } + const waitingProviderThreadId = ProviderThreadId.make("provider-thread:ownership:waiting-run"); + const waitingRunId = yield* createRun(threadId, "completed", { + ordinal: 6, + providerThreadId: waitingProviderThreadId, + }); + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items + (turn_item_id, thread_id, run_id, node_id, provider_thread_id, ordinal, type, + status, updated_at, payload_json) + VALUES ('item:ownership', ${threadId}, ${waitingRunId}, ${ownerNodeId}, + ${ProviderThreadId.make("provider-thread:ownership:item")}, 1, 'assistant_message', 'waiting', '', '{}') + `; + const statements: Array["compile"]>> = []; + const observe: Statement.Transformer = (statement) => { + const compiled = statement.compile(); + if (compiled[0].includes("SELECT provider_thread.payload_json")) statements.push(compiled); + return Effect.succeed(statement); + }; + const projection = yield* projections + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.provideService(Statement.CurrentTransformer, observe)); + assert.deepEqual( + projection.providerThreads.map((row) => row.id), + cases + .filter((entry) => entry.included) + .toSorted((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0) || a.name.localeCompare(b.name)) + .map((entry) => ProviderThreadId.make(`provider-thread:ownership:${entry.name}`)), + ); + assert.equal(statements.length, 1); + const [query, params] = statements[0]!; + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + assert.isFalse( + plan.some((row) => /^SCAN provider_thread(?:$| )/.test(row.detail)), + JSON.stringify(plan), + ); + assert.isTrue( + plan.some((row) => row.detail.includes("provider_threads_owner_idx")), + JSON.stringify(plan), + ); + assert.isFalse( + plan.some((row) => /SEARCH (owner|n) USING INDEX .*nodes_thread/.test(row.detail)), + JSON.stringify(plan), + ); + }).pipe(Effect.provide(layerTest)), +); + it.effect("reads the run that owns a background roster, not a queued or resumed one", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 31094caf7d6f2..4d6f9f166a5f4 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -4587,18 +4587,20 @@ export const layer: Layer.Layer = sql` SELECT provider_thread.payload_json FROM orchestration_v2_projection_provider_threads AS provider_thread - WHERE ( - provider_thread.thread_id = ${threadId} - OR EXISTS ( - SELECT 1 FROM orchestration_v2_projection_nodes AS owner - WHERE owner.node_id = provider_thread.owner_node_id - AND owner.thread_id = ${threadId} - ) - OR EXISTS ( - SELECT 1 FROM orchestration_v2_projection_subagents AS subagent - WHERE subagent.provider_thread_id = provider_thread.provider_thread_id - AND subagent.thread_id = ${threadId} - ) + WHERE provider_thread.provider_thread_id IN ( + SELECT provider_thread_id FROM orchestration_v2_projection_provider_threads + WHERE thread_id = ${threadId} + UNION + -- Start with non-null owners; a thread can have a large settled node history. + SELECT owned.provider_thread_id + FROM orchestration_v2_projection_provider_threads AS owned + INDEXED BY orchestration_v2_projection_provider_threads_owner_idx + CROSS JOIN orchestration_v2_projection_nodes AS owner + ON owner.node_id = owned.owner_node_id + WHERE owned.owner_node_id IS NOT NULL AND owner.thread_id = ${threadId} + UNION + SELECT provider_thread_id FROM orchestration_v2_projection_subagents + WHERE thread_id = ${threadId} AND provider_thread_id IS NOT NULL ) AND ( provider_thread.status = 'active' From d9e212b416a3d92ee59f400644384177e172c158 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:53:14 -0600 Subject: [PATCH 08/25] perf: avoid eager thread-family loading Adapted from upstream eef35b0262. (cherry picked from commit bff8309b0761f59c687c7f0cff32304e9ef17105) --- .../features/threads/ThreadRouteScreen.tsx | 8 +-- apps/mobile/src/state/threads.ts | 13 ----- apps/web/src/components/ChatView.tsx | 10 +--- .../chat/ThreadLineageWorkflowRow.tsx | 8 +-- ...hreadRelationshipsControl.browser.test.tsx | 51 ++++++++++++++++++- .../chat/ThreadRelationshipsControl.tsx | 10 +--- apps/web/src/state/threads.ts | 14 ----- .../src/state/threadRelationships.test.ts | 34 +++++++++++++ .../src/state/threadRelationships.ts | 7 ++- 9 files changed, 95 insertions(+), 60 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 737e1aff90ada..a7a10a8675122 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -80,7 +80,7 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows"; -import { threadEnvironment, useThreadFamily } from "../../state/threads"; +import { threadEnvironment } from "../../state/threads"; import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads"; import { useProjects } from "../../state/entities"; import { projectThreadContentPresentation } from "./threadContentPresentation"; @@ -243,12 +243,6 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { const connectionsReady = useConnectionsReady(); const { connectionState } = useRemoteConnectionStatus(); const { selectedThread } = useThreadSelection(); - // Subagent rows, the open child's own row and its ancestors come from the family when the - // shell leaves idle children out. - useThreadFamily( - selectedThread?.environmentId ?? null, - selectedThread?.lineage.rootThreadId ?? null, - ); const params = props.route.params ?? {}; const environmentIdRaw = firstRouteParam(params.environmentId); const threadIdRaw = firstRouteParam(params.threadId); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 4cb0f92b4ee83..6ba5c635685a3 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -11,7 +11,6 @@ import { } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -45,18 +44,6 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ snapshotAtom: threadEnvironment.snapshotAtom, }); -/** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */ -export function useThreadFamily( - environmentId: EnvironmentId | null, - rootThreadId: ThreadId | null, -): void { - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the subagent tree under the root thread into the shared thread registry while the caller shows it - useEffect(() => { - if (environmentId === null || rootThreadId === null) return; - return threadFamilies.mount(appAtomRegistry, environmentId, rootThreadId); - }, [environmentId, rootThreadId]); -} - // Long enough for the opened screen to subscribe; its subscription keeps the thread after this. const OPENING_THREAD_HOLD_MS = 3_000; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e15424cf090fa..d2a176a444369 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -234,7 +234,7 @@ import { primaryServerKeybindingsAtom, serverEnvironment, } from "../state/server"; -import { threadEnvironment, useThreadFamily } from "../state/threads"; +import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { @@ -483,14 +483,6 @@ export default function ChatView(props: ChatViewProps) { }); const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; - // Subagent rows, an open child's own row and its ancestors come from the family when the shell - // leaves idle children out. Every source agrees on the root, so the key never flips. - useThreadFamily( - routeKind === "server" ? environmentId : null, - serverThreadShell?.lineage.rootThreadId ?? - serverProjection?.thread.lineage.rootThreadId ?? - null, - ); // Only the shell-less fallback needs these; with a shell, the shell carries them. const fallbackDetailRef = serverThreadShell === null ? routeThreadDetailRef : null; const fallbackUpdatedAt = useThreadUpdatedAt(fallbackDetailRef); diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index 4bc59403a7d33..9d31ec35761e9 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -56,7 +56,6 @@ function WorkflowMemberRow({ providerEntries, driver, onOpen, - isThreadAvailable, }: { member: RuntimeSubagent; providerInstanceId: ProviderInstanceId; @@ -65,10 +64,9 @@ function WorkflowMemberRow({ providerEntries: ReadonlyArray; driver: ProviderDriverKind | undefined; onOpen: (threadId: string) => void; - isThreadAvailable: (threadId: string) => boolean; }) { const threadId = member.childThreadId; - const canOpen = threadId !== null && isThreadAvailable(threadId); + const canOpen = threadId !== null; return (
  • @@ -126,7 +124,6 @@ export function ThreadLineageWorkflowRow({ providerEntries, driver, onOpenThread, - isThreadAvailable, onStop, stopping, stopDisabled, @@ -139,7 +136,6 @@ export function ThreadLineageWorkflowRow({ readonly providerEntries: ReadonlyArray; readonly driver: ProviderDriverKind | undefined; readonly onOpenThread: (threadId: string) => void; - readonly isThreadAvailable: (threadId: string) => boolean; readonly onStop?: (() => void) | undefined; readonly stopping: boolean; readonly stopDisabled: boolean; @@ -238,7 +234,6 @@ export function ThreadLineageWorkflowRow({ providerEntries={providerEntries} driver={driver} onOpen={onOpenThread} - isThreadAvailable={isThreadAvailable} /> ))} @@ -256,7 +251,6 @@ export function ThreadLineageWorkflowRow({ providerEntries={providerEntries} driver={driver} onOpen={onOpenThread} - isThreadAvailable={isThreadAvailable} /> ))} {phases.length === 0 && group.unphasedMembers.length === 0 ? ( diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 959d0ac724f23..3124cc7b3fa13 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, NodeId, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; @@ -34,6 +34,7 @@ vi.mock(import("../../state/use-atom-command"), () => ({ })); import { render } from "../../../test/render"; +import { makeThreadProjectionFixture } from "../../test-fixtures"; import { ToastProvider } from "../ui/toast"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; @@ -134,3 +135,51 @@ it.each([ await expect.element(screen.getByRole("button", { name: "More thread actions" })).toBeEnabled(); } }); + +it("opens an idle child from Lineage when its shell has not been loaded", async () => { + setup(); + const projection = makeThreadProjectionFixture(); + const childId = ThreadId.make("omitted-child"); + const environmentId = EnvironmentId.make("local"); + state.projection = { + ...projection, + subagents: [ + { + id: NodeId.make("agent"), + threadId: projection.thread.id, + runId: null, + parentNodeId: NodeId.make("parent"), + origin: "app_owned", + createdBy: "agent", + driver: "codex", + providerInstanceId: projection.thread.providerInstanceId, + providerThreadId: null, + childThreadId: childId, + nativeTaskRef: null, + prompt: "Inspect the change", + title: "Idle child", + model: null, + status: "completed", + result: null, + startedAt: null, + completedAt: projection.thread.updatedAt, + updatedAt: projection.thread.updatedAt, + }, + ], + }; + await using screen = await render( + , + ); + await screen.getByRole("button", { name: /Previous agents/ }).click(); + const child = screen.getByRole("button", { name: /Idle child/ }); + await expect.element(child).toBeEnabled(); + await child.click(); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe("/local/omitted-child"), + ); +}); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 04068b6ace3a7..a3eaf6e787baa 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -514,6 +514,7 @@ export function ThreadRelationshipsPanel(props: { return null; } + // Idle children can be omitted from the shell; opening one loads its own detail. const openThread = (threadId: ThreadId) => { if (openSideChatTab(ref, graph.nodes.get(threadId)?.thread ?? null)) return; void navigate({ @@ -657,9 +658,7 @@ export function ThreadRelationshipsPanel(props: { ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" : ""; const project = projects.find((project) => project.id === node?.thread?.projectId); - const relationshipHint = node?.missing - ? "This related thread is unavailable" - : `Open ${relationship.toLowerCase()} in this chat`; + const relationshipHint = `Open ${relationship.toLowerCase()} in this chat`; const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup; const relationshipTooltip = agent ? ( openThread(threadId)} /> } @@ -749,9 +747,6 @@ export function ThreadRelationshipsPanel(props: { providerEntries={providerEntries} driver={providerDriver} onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)} - isThreadAvailable={(memberThreadId) => - graph.nodes.get(memberThreadId as ThreadId)?.missing === false - } onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} stopping={stoppingId === agent.id} stopDisabled={stoppingId !== null} @@ -772,7 +767,6 @@ export function ThreadRelationshipsPanel(props: { variant="ghost" part="primary" aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`} - disabled={node?.missing === true} onClick={() => openThread(threadId)} /> } diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 07fffa97cfbe0..c7919d658ccf6 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -14,11 +14,9 @@ import { import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentShell } from "./shell"; export const threadFamilies = createThreadFamilyAtoms( @@ -42,18 +40,6 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ snapshotAtom: threadEnvironment.snapshotAtom, }); -/** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */ -export function useThreadFamily( - environmentId: EnvironmentId | null, - rootThreadId: ThreadId | null, -): void { - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the subagent tree into the thread family registry while the caller shows it - useEffect(() => { - if (environmentId === null || rootThreadId === null) return; - return threadFamilies.mount(appAtomRegistry, environmentId, rootThreadId); - }, [environmentId, rootThreadId]); -} - const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("web-environment-thread:empty"), ); diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 9070b7628af1d..90dc68c21dd82 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -59,6 +59,40 @@ describe("thread relationships", () => { ).toBeNull(); }); + it.each(["fork", "subagent"] as const)( + "keeps the parent edge of an opened %s omitted from the shell", + (relationshipToParent) => { + const parent = ThreadId.make("parent"); + const child = ThreadId.make("omitted-child"); + const projection = { + thread: { + id: child, + forkedFrom: + relationshipToParent === "fork" + ? { type: "run", threadId: parent, runId: "source-run" } + : null, + lineage: { rootThreadId: parent, parentThreadId: parent, relationshipToParent }, + }, + subagents: [], + contextTransfers: [], + } as never; + const graph = deriveThreadRelationshipGraph({ threads: [], projection }); + expect(immediateThreadRelationships(graph, child)).toEqual([ + expect.objectContaining({ + threadId: parent, + edge: expect.objectContaining({ + sourceThreadId: parent, + targetThreadId: child, + kind: relationshipToParent, + }), + }), + ]); + expect(resolveMergeBackTargetThreadId(projection)).toBe( + relationshipToParent === "fork" ? parent : null, + ); + }, + ); + it("keeps an older activity run visible over a newer cancelled run", () => { const parent = ThreadId.make("thread-parent"); const child = ThreadId.make("thread-child"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index a0c63adccaaca..2aecc93470c2f 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -80,7 +80,12 @@ export function deriveThreadRelationshipGraph(input: { ); }; - for (const thread of threads) { + const selectedThread = input.projection?.thread; + const lineageThreads = + selectedThread !== undefined && !threadsById.has(selectedThread.id) + ? [...threads, { ...selectedThread, activityRunStatus: null, status: null }] + : threads; + for (const thread of lineageThreads) { const parentThreadId = thread.forkedFrom?.type === "run" ? thread.forkedFrom.threadId From 479e864c78d694a82a9f752ac6f178f679db8e6c Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:54:03 -0600 Subject: [PATCH 09/25] perf: limit sidebar draft rerenders Adapted from upstream 39bca5365c. (cherry picked from commit 4331891952611e9e77aa51352c0620425483f90a) --- .../SidebarDraftBlock.browser.test.tsx | 68 +++++++++++++++++++ .../components/sidebar/SidebarDraftBlock.tsx | 11 ++- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx new file mode 100644 index 0000000000000..c29932f5757e1 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx @@ -0,0 +1,68 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { Profiler } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { SidebarDraftBlock } from "./SidebarDraftBlock"; + +it("keeps draft previews current through edits, promotion and removal", async () => { + const initial = useComposerDraftStore.getState(); + using _store = { + [Symbol.dispose]: () => useComposerDraftStore.setState(initial, true), + }; + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + const environmentId = EnvironmentId.make("draft-preview-environment"); + const projectRef = scopeProjectRef(environmentId, ProjectId.make("draft-preview-project")); + const draftId = DraftId.make("draft-preview"); + const threadId = ThreadId.make("draft-preview-thread"); + const onRender = vi.fn(); + await using screen = await render( + +
      + +
    +
    , + ); + const row = screen.getByTestId("sidebar-draft-row"); + await expect.element(row).not.toBeInTheDocument(); + + await act(() => { + initial.setProjectDraftThreadId(projectRef, draftId, { threadId }); + initial.setPrompt(draftId, "First draft preview"); + }); + await expect.element(row).toHaveAccessibleName("First draft preview, Unsent draft"); + + const rendersBeforeOtherComposer = onRender.mock.calls.length; + await act(() => + initial.setPrompt(scopeThreadRef(environmentId, ThreadId.make("other")), "Other composer"), + ); + expect(onRender).toHaveBeenCalledTimes(rendersBeforeOtherComposer); + await act(() => initial.setPrompt(draftId, "Updated draft preview")); + await expect.element(row).toHaveAccessibleName("Updated draft preview, Unsent draft"); + + await act(() => initial.markDraftThreadPromoting(draftId)); + await expect.element(row).not.toBeInTheDocument(); + await act(() => initial.clearDraftThread(draftId)); + + const nextDraftId = DraftId.make("next-draft-preview"); + await act(() => { + initial.setProjectDraftThreadId(projectRef, nextDraftId, { threadId: ThreadId.make("next") }); + initial.setPrompt(nextDraftId, "Next draft preview"); + }); + await expect.element(row).toHaveAccessibleName("Next draft preview, Unsent draft"); + await act(() => initial.clearDraftThread(nextDraftId)); + await expect.element(row).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index a9301f30cb0e5..10581ecb5a679 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -8,6 +8,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { useShallow } from "zustand/react/shallow"; import { cn } from "~/lib/utils"; import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; @@ -147,7 +148,15 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const draftsByThreadKey = useComposerDraftStore( + useShallow((store) => + Object.fromEntries( + Object.keys(store.draftThreadsByThreadKey).map( + (key) => [key, store.draftsByThreadKey[key]] as const, + ), + ), + ), + ); const [frozenActive, setFrozenActive] = useState<{ routeDraftId: string | null; row: SidebarDraftRowData | null; From 771fa6d22c52bbb095604bd5c9926f14022bd351 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:54:47 -0600 Subject: [PATCH 10/25] perf: reduce side-chat rerenders Adapted from upstream 2f777bdd49. (cherry picked from commit 1e3b735e2599f94b58def47330812f0a5f5d0cb6) --- apps/web/src/components/ChatView.tsx | 10 ++++++---- .../src/components/chat/RightPanelSurfaceContent.tsx | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d2a176a444369..c801ee29e9094 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3186,6 +3186,11 @@ export default function ChatView(props: ChatViewProps) { (folders: Parameters[0]) => composerRef.current?.addDroppedFolders(folders), ); + // Above the early return, where React Compiler can cache it for the side chat pane. + const sideChatParentPanel = { + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }; if (!activeThread) { return embedded ? null : ; @@ -3205,10 +3210,7 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} - sideChatParentPanel={{ - openToolDiff: onOpenToolDiffStable, - runShellCommand: runSideChatShellCommandStable, - }} + sideChatParentPanel={sideChatParentPanel} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 6ca50c824cb45..b703e5d05da97 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -79,7 +79,7 @@ export function RightPanelSurfaceContent(props: { readonly files: RightPanelFileProps; readonly sideChatParentPanel: SideChatParentPanel; }) { - const { surface, threadRef, visible } = props; + const { surface, threadRef, visible, onCloseSurface } = props; if (surface === null) return null; switch (surface.kind) { case "preview": @@ -176,7 +176,7 @@ export function RightPanelSurfaceContent(props: { parentRef={threadRef} threadId={surface.threadId} parentPanel={props.sideChatParentPanel} - onClose={() => props.onCloseSurface(surface)} + onClose={() => onCloseSurface(surface)} /> ); case "device": From fd71fb7f991fede3041c0b90174e76cae422c7aa Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:47:58 -0600 Subject: [PATCH 11/25] feat(clients): surface child approvals in parent threads Adapt upstream 70f475f511 for web and mobile. Route actions to the child environment and preserve existing provider policy. (cherry picked from commit 066b62823d8eccd1b6228061c23733e222bb189c) --- .../features/threads/ChildThreadApprovals.tsx | 88 +++++++ .../features/threads/ThreadDetailScreen.tsx | 248 ++++++++++-------- apps/mobile/src/state/threads.ts | 5 + apps/web/src/components/ChatView.tsx | 2 + .../components/chat/ChildThreadApprovals.tsx | 95 +++++++ apps/web/src/state/threads.ts | 5 + docs/user/permission-modes.md | 5 +- .../src/state/threadRequests.test.ts | 72 ++++- .../src/state/threadRequests.ts | 49 ++++ 9 files changed, 450 insertions(+), 119 deletions(-) create mode 100644 apps/mobile/src/features/threads/ChildThreadApprovals.tsx create mode 100644 apps/web/src/components/chat/ChildThreadApprovals.tsx diff --git a/apps/mobile/src/features/threads/ChildThreadApprovals.tsx b/apps/mobile/src/features/threads/ChildThreadApprovals.tsx new file mode 100644 index 0000000000000..24d7178c6c7c7 --- /dev/null +++ b/apps/mobile/src/features/threads/ChildThreadApprovals.tsx @@ -0,0 +1,88 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; +import { useEnvironmentScope } from "../../state/session"; +import { ScrollView, View } from "react-native"; +import Animated, { useAnimatedStyle, type DerivedValue } from "react-native-reanimated"; +import { AppText } from "../../components/ui/AppText"; +import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { PendingApprovalCard } from "./PendingApprovalCard"; + +function ChildApproval({ entry }: { entry: ChildThreadApproval }) { + const canRespond = useEnvironmentScope(entry.thread.environmentId, AuthOrchestrationOperateScope); + const respond = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false }); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(null); + return ( + + + Approval from {entry.title} + + { + if (!canRespond || responding || entry.approval.responseCapability !== "live") return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> + {error !== null ? ( + + {error} + + ) : null} + + ); +} + +export function ChildThreadApprovals({ + threadRef, + maxHeight, +}: { + threadRef: ScopedThreadRef | null; + maxHeight: DerivedValue; +}) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + const heightStyle = useAnimatedStyle(() => ({ maxHeight: Math.min(320, maxHeight.value / 2) })); + if (approvals.length === 0) return null; + return ( + + + {approvals.map((entry) => ( + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 870ab31f00003..a71920c199f17 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { ChildThreadApprovals } from "./ChildThreadApprovals"; import { useOpenThreadInspector } from "./thread-inspector-navigation"; import { useSidebarWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; @@ -306,6 +307,8 @@ export interface ThreadDetailScreenProps { readonly showContent?: boolean; } +// iOS questions overlay the controls; Android questions stack with child approvals. +const ComposerPanels = Platform.OS === "android" ? Animated.ScrollView : Animated.View; const USAGE_LIMITS_PANEL_GAP = 12; const USAGE_LIMITS_PANEL_MIN_HEIGHT = 44; @@ -960,6 +963,10 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { composerOverlapHeight: composerBottomInset, }), ); + const composerPanelsHeightStyle = useAnimatedStyle(() => ({ + maxHeight: pendingUserInputMaxHeight.value, + flexGrow: 0, + })); // The usage panel stacks with the composer and any approval card, so it gets // whatever room they leave below the header. Measured only while it is open // so typing in the composer does not re-render the screen. @@ -1504,130 +1511,141 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { }); }} /> - {props.queuedRunEdit !== null ? ( - - { - voiceInputSession.cancel(props.composerDraftKey); - props.onCancelQueuedRunEdit(); - }} - /> - - ) : null} - - - - {props.draftMode ? null : ( - + {props.queuedRunEdit !== null ? ( + + { + voiceInputSession.cancel(props.composerDraftKey); + props.onCancelQueuedRunEdit(); + }} + /> + + ) : null} + - )} - {props.feedbackSubmissions.map((submission) => ( - props.onDismissFeedback(submission.id)} + - ))} - {composerError !== null ? ( - - clearThreadComposerError(selectedThreadKey)} - /> - - ) : null} - {usageLimitsReport && activeUserInputRequestId === null ? ( - - + + {props.draftMode ? null : ( + - - ) : null} - {props.creationState?.kind === "failed" ? ( - - ( + props.onDismissFeedback(submission.id)} /> - - ) : null} - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - + clearThreadComposerError(selectedThreadKey)} /> - ) : null} - {props.activePendingUserInput ? ( - + ) : null} + {usageLimitsReport && activeUserInputRequestId === null ? ( + + - ) : null} - - ) : null} + + ) : null} + {props.creationState?.kind === "failed" ? ( + + + + ) : null} + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 6ba5c635685a3..bfd088ccf7f44 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,3 +1,4 @@ +import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, @@ -43,6 +44,10 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); +export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, + pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, +}); // Long enough for the opened screen to subscribe; its subscription keeps the thread after this. const OPENING_THREAD_HOLD_MS = 3_000; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c801ee29e9094..387a7f26d90a4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { ChildThreadApprovals } from "./chat/ChildThreadApprovals"; import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; @@ -3548,6 +3549,7 @@ export default function ChatView(props: ChatViewProps) { : undefined } > + (null); + const { approval } = entry; + return ( +
    +

    Approval needed from {entry.title}

    +
    +        {approval.detail || approval.requestKind}
    +      
    + {approval.appName ?

    {approval.appName}

    : null} + {approval.responseCapability !== "live" ? ( +

    + Provider process is gone. Interrupt or restart the child thread to respond. +

    + ) : null} + {approval.reason ?

    {approval.reason}

    : null} + {approval.fullAccessOverride ? ( +

    + {fullAccessOverrideExplanation(approval.fullAccessOverride)} +

    + ) : null} +
    + { + if (!canRespond || responding || entry.approval.responseCapability !== "live") return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> +
    + {error !== null ? ( +

    + {error} +

    + ) : null} +
    + ); +} + +export function ChildThreadApprovals({ threadRef }: { threadRef: ScopedThreadRef | null }) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + if (approvals.length === 0) return null; + return ( +
    + {approvals.map((entry) => ( + + ))} +
    + ); +} diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index c7919d658ccf6..9d7aae58033f1 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -1,3 +1,4 @@ +import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; import { arrayElementsEqual } from "@t3tools/client-runtime/state/entities"; @@ -39,6 +40,10 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); +export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, + pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, +}); const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("web-environment-thread:empty"), diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 16b5811b019c3..984f689b091a4 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -17,8 +17,9 @@ and modes you choose in a draft keep their permissions. | **Auto** | Uses the provider's automatic review to approve routine actions and ask about others. | | **Full access** | Allows commands and edits without approval prompts. | -Approve or reject requests in the conversation to let the agent continue. Permission modes do -not prevent the agent from asking questions about the task. +Approve or reject requests in the conversation to let the agent continue. Agents can ask +questions in any permission mode. Child approvals appear in the parent conversation with the +child thread’s title. ## Provider differences diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index d5d2c4ec16d8d..95ee3eceffdd9 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -1,5 +1,9 @@ +import { presentThreadShell } from "./models.ts"; +import { threadKey } from "./entities.ts"; import { EnvironmentId, + ThreadId, + type ScopedThreadRef, NodeId, ProviderSessionId, RuntimeRequestId, @@ -7,11 +11,13 @@ import { type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { AsyncResult } from "effect/reactivity"; +import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; import * as Cause from "effect/Cause"; -import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts"; +import { v2Now, v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { createQuestionHistoryProjector, + createChildThreadApprovalsAtom, + type PendingThreadRequests, derivePendingThreadRequests, declinePendingApprovals, sendApprovalFeedback, @@ -353,3 +359,65 @@ it("restores old text answers without mutating history or replacing unchanged ro expect(project(answered)).toBe(result); expect(project({ ...answered, visibleTurnItems: [...rows] })[0]).toBe(result[0]); }); + +it("loads only child approvals and retains their destination across environments", () => { + const local = EnvironmentId.make("local"); + const remote = EnvironmentId.make("remote"); + const parent = { environmentId: local, threadId: ThreadId.make("parent") }; + const childId = ThreadId.make("child"); + const children = Atom.make( + [local, remote].map((environmentId) => ({ + ...presentThreadShell(environmentId, v2ThreadShell), + id: childId, + title: environmentId, + hasPendingApprovals: true, + lastVisitedAt: environmentId === local ? null : "2026-10-19T00:00:00Z", + })), + ); + const details = Atom.family((_key: string) => + Atom.make({ + approvals: [ + { + requestId: RuntimeRequestId.make("same-request"), + requestKind: "command", + createdAt: "2026-10-19T00:00:00Z", + responseCapability: "live", + }, + ], + userInputs: [], + }), + ); + const readDetails = vi.fn((ref: ScopedThreadRef) => details(threadKey(ref))); + const approvalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: () => children, + pendingRequestsAtom: readDetails, + }); + const registry = AtomRegistry.make(); + const unmount = registry.mount(approvalsAtom(parent)); + const pending = registry.get(approvalsAtom(parent)); + expect(pending.map((entry) => entry.thread)).toEqual( + [local, remote].map((environmentId) => ({ environmentId, threadId: childId })), + ); + expect(pending.map((entry) => entry.title)).toEqual(["local", "remote"]); + registry.set( + children, + registry.get(children).map((child) => ({ ...child, lastVisitedAt: "2026-10-19T00:00:00Z" })), + ); + expect(registry.get(approvalsAtom(parent))).toBe(pending); + registry.set(details(threadKey({ environmentId: remote, threadId: childId })), { + approvals: [], + userInputs: [], + }); + expect(registry.get(approvalsAtom(parent))).toEqual([pending[0]]); + readDetails.mockClear(); + registry.set( + children, + registry + .get(children) + .map((child) => ({ ...child, hasPendingApprovals: false, hasPendingUserInput: true })), + ); + expect(registry.get(approvalsAtom(parent))).toEqual([]); + expect(readDetails).not.toHaveBeenCalled(); + unmount(); + registry.dispose(); +}); diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index e089eb36d08c7..a010018c5051d 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, + ScopedThreadRef, ThreadId, OrchestrationV2ThreadProjection, OrchestrationV2RuntimeRequest, @@ -10,6 +11,9 @@ import type { ProviderRequestKind, RuntimeRequestId, } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { parseThreadKey, threadKey } from "./entities.ts"; +import type { EnvironmentThreadShell } from "./models.ts"; import * as DateTime from "effect/DateTime"; import type { AtomCommandResult } from "./runtime.ts"; import type { ThreadStructure, ThreadStructureVisibleTurnItem } from "./models.ts"; @@ -339,3 +343,48 @@ export function createQuestionHistoryProjector< return previousResult; }; } + +export interface ChildThreadApproval { + readonly thread: ScopedThreadRef; + readonly title: string; + readonly approval: ThreadPendingApproval; +} + +export function createChildThreadApprovalsAtom(input: { + readonly childThreadInputsAtom: ( + ref: ScopedThreadRef, + ) => Atom.Atom>; + readonly pendingRequestsAtom: (ref: ScopedThreadRef) => Atom.Atom; +}) { + const family = Atom.family((key: string) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(input.childThreadInputsAtom(parseThreadKey(key))).flatMap((child) => { + if (!child.hasPendingApprovals) return []; + const thread = { environmentId: child.environmentId, threadId: child.id }; + // Only children asking for approval need a detail subscription. + return (get(input.pendingRequestsAtom(thread))?.approvals ?? []).map((approval) => ({ + thread, + title: child.title, + approval, + })); + }); + if ( + next.length === previous.length && + next.every((entry, index) => { + const old = previous[index]!; + return ( + old.approval === entry.approval && + old.title === entry.title && + threadKey(old.thread) === threadKey(entry.thread) + ); + }) + ) + return previous; + previous = next; + return next; + }); + }); + const empty = Atom.make>([]); + return (ref: ScopedThreadRef | null) => (ref === null ? empty : family(threadKey(ref))); +} From 252a35b651e1b5f520b26e1e1118f2d23a90519f Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:54:41 -0600 Subject: [PATCH 12/25] feat(scheduler): support one-time scheduled tasks Adapt upstream 4ac97be2b6. Persist UTC instants and catch up enabled one-time tasks on restart. Keep the fork schedule filter and MCP policy compatible with the new trigger. (cherry picked from commit 87f01f913ed1522474e3526f160ebdc8c3d9dc72) --- .../SettingsScheduledTasksRouteScreen.tsx | 84 +++++- .../features/settings/scheduledTaskDraft.ts | 12 +- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/mcp/toolkits/core.test.ts | 3 + .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/provider/agentContextBudget.json | 6 +- .../src/scheduledTasks/Schedule.test.ts | 6 + apps/server/src/scheduledTasks/Schedule.ts | 9 + .../ScheduledTaskService.interval.test.ts | 252 ++++++++++++++++++ .../scheduledTasks/ScheduledTaskService.ts | 201 ++++++++++---- .../ScheduledTaskService.webhook.test.ts | 26 ++ .../ScheduledTasksSettings.browser.test.tsx | 4 +- .../settings/ScheduledTasksSettings.tsx | 80 +++++- .../scheduledTasksSettings.logic.test.ts | 38 ++- .../settings/scheduledTasksSettings.logic.ts | 50 +++- docs/user/project-settings.md | 8 +- evals/mcp-tool-use/cases/pull-requests.ndjson | 3 +- packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestratorMcp.ts | 2 +- packages/contracts/src/scheduledTask.test.ts | 16 ++ packages/contracts/src/scheduledTask.ts | 30 +++ .../src/server/orchestrationInstructions.ts | 2 +- packages/shared/src/t3McpToolPresentation.ts | 5 +- 24 files changed, 763 insertions(+), 89 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index fcb66ae1e6262..f94878cfb81f8 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -109,6 +109,10 @@ const DAYS = [ function describeSchedule(task: ScheduledTask): string { if (task.schedule.type === "webhook") return "On webhook"; + if (task.schedule.type === "once") { + const runAt = new Date(task.schedule.runAt); + return `Once, ${formatRunAtDate(runAt)} at ${formatRunAtTime(runAt)}`; + } if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs); const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day"; return `${days} at ${formatTime(task.schedule.timeOfDay)}`; @@ -122,6 +126,14 @@ function formatTime(value: string): string { return time.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } +function formatRunAtDate(value: Date): string { + return value.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); +} + +function formatRunAtTime(value: Date): string { + return value.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + function timePickerValue(value: string): Date { const [hours, minutes] = value.split(":").map(Number); const time = new Date(); @@ -687,6 +699,7 @@ function TaskForm({ }); const submissionPending = useRef(false); const [timePickerOpen, setTimePickerOpen] = useState(false); + const [runAtPicker, setRunAtPicker] = useState<"date" | "time" | null>(null); const taskMissing = draft.task !== null && tasks.data !== null && @@ -701,6 +714,9 @@ function TaskForm({ } }; + const offersOnce = + config?.environment.capabilities.oneTimeSchedules === true || + draft.task?.schedule.type === "once"; const save = async () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || @@ -728,6 +744,13 @@ function TaskForm({ ); return; } + if (draft.schedule.mode === "once" && !offersOnce) { + Dialog.alert( + "Once is not available here", + "This environment runs an older T3 Code. Update it to schedule a one-time task, or pick another schedule.", + ); + return; + } const parsedFilters = parseWebhookFilterDrafts(draft.schedule.filters); if (draft.schedule.mode === "webhook" && "error" in parsedFilters) { Dialog.alert("Invalid filter", parsedFilters.error); @@ -964,19 +987,31 @@ function TaskForm({ variant="card" options={[ { value: "fixed_time", label: "At a time" }, + // Older servers reject one-time schedules on save. + ...(offersOnce || draft.schedule.mode === "once" + ? [{ value: "once" as const, label: "Once" }] + : []), { value: "interval", label: "Interval" }, { value: "webhook", label: "On webhook" }, ]} selected={draft.schedule.mode} onSelect={(mode) => { setTimePickerOpen(false); + setRunAtPicker(null); setDraft({ ...draft, prompt: mode === "webhook" && !draft.prompt.trim() ? DEFAULT_WEBHOOK_PROMPT : draft.prompt, - schedule: { ...draft.schedule, mode }, + schedule: { + ...draft.schedule, + mode, + runAt: + mode === "once" && !draft.schedule.runAt + ? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString() + : draft.schedule.runAt, + }, }); }} /> @@ -1058,6 +1093,53 @@ function TaskForm({ }} /> + ) : draft.schedule.mode === "once" ? ( + (["date", "time"] as const).map((field) => { + const runAt = new Date(draft.schedule.runAt); + const label = field === "date" ? formatRunAtDate(runAt) : formatRunAtTime(runAt); + return ( + + setRunAtPicker((open) => (open === field ? null : field))} + className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" + > + + {field === "date" ? "Date" : "Time"} + + + {label} + + + + {runAtPicker === field ? ( + setRunAtPicker(null)} + onValueChange={(_, selected) => { + // Android shows a dialog that closes on pick; iOS stays inline. + if (Platform.OS === "android") setRunAtPicker(null); + setDraft({ + ...draft, + schedule: { ...draft.schedule, runAt: selected.toISOString() }, + }); + }} + /> + ) : null} + + ); + }) ) : draft.schedule.mode === "webhook" ? ( <> ; readonly intervalMinutes: string; readonly signature: ScheduledTaskWebhookSignature | null; @@ -69,6 +71,7 @@ export type ScheduleDraft = { export const DEFAULT_SCHEDULE: ScheduleDraft = { mode: "fixed_time", timeOfDay: "09:00", + runAt: "", weekdays: [1, 2, 3, 4, 5], intervalMinutes: "15", signature: null, @@ -90,6 +93,8 @@ export function scheduleDraftForTask(task: Pick): Sch ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6], }; + case "once": + return { ...DEFAULT_SCHEDULE, mode: "once", runAt: task.schedule.runAt }; case "interval": return { ...DEFAULT_SCHEDULE, @@ -167,6 +172,10 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche : {}), }; } + if (draft.mode === "once") { + const runAt = new Date(draft.runAt); + return Number.isNaN(runAt.getTime()) ? null : { type: "once", runAt: runAt.toISOString() }; + } if (draft.mode === "interval") { const minutes = parseIntervalMinutes(draft.intervalMinutes); const everyMs = Math.round(minutes * 60_000); @@ -216,6 +225,7 @@ function draftSignature(draft: ScheduledTaskDraft): string { .map((option) => [option.id, option.value]), draft.schedule.mode, draft.schedule.timeOfDay, + draft.schedule.runAt, [...draft.schedule.weekdays].sort((a, b) => a - b), draft.schedule.intervalMinutes, draft.schedule.maxDeliveryAgeMinutes, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c327b507d9bb7..5f1ed34d23845 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -271,6 +271,7 @@ export const make = Effect.gen(function* () { projectOrganization: true, ignoredFileListing: true, triage: true, + oneTimeSchedules: true, npmReleaseLookup: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 0cda9ef352619..59b2fcfe1e8e3 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -738,11 +738,14 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode expect(declaredFailure(update)).toMatchObject({ code: "runtime_mode_escalation_denied" }); const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" }); expect(declaredFailure(remove)).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const once = { type: "once", runAt: "2026-11-01T01:30:00-05:00" }; const allowed = yield* call("schedule_task", { scheduledTaskId: "task-auto", enabled: false, + schedule: once, }); expect(allowed.isError).toBe(false); + expect(allowed.structuredContent).toMatchObject({ schedule: once, enabled: false }); const incomplete = yield* call("schedule_task", { prompt: "Run nightly" }); expect(declaredFailure(incomplete)).toMatchObject({ code: "invalid_request" }); }).pipe( diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 413ab984211f8..7c05f5c8d8e25 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -165,7 +165,7 @@ const ScheduleTaskTool = Tool.make("schedule_task", { failureMode: "return", dependencies, }) - .annotate(Tool.Title, "Schedule a recurring task") + .annotate(Tool.Title, "Schedule a task") .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, true); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 468af348cb352..ea562da9528bf 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 11642, - "codexAdditionalContextBytes": 13362, - "mcpToolListJsonBytes": 49761 + "claudeSystemAppendBytes": 11716, + "codexAdditionalContextBytes": 13436, + "mcpToolListJsonBytes": 49842 } diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index f5973969747f7..0941c7392ff94 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -27,6 +27,12 @@ describe("scheduled task schedule calculation", () => { }); it("compares schedules structurally", () => { + expect( + isSameSchedule( + { type: "once", runAt: "2026-11-01T03:00:00-05:00" }, + { type: "once", runAt: "2026-11-01T08:00:00.000Z" }, + ), + ).toBe(true); expect( isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }), ).toBe(true); diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index bac32bb913146..f8fb648aaaaa9 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -1,5 +1,6 @@ import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; const MINUTE_MS = 60_000; @@ -14,6 +15,13 @@ export function nextScheduledRunAt( from: DateTime.DateTime, ): DateTime.DateTime | null { if (schedule.type === "webhook") return null; + if (schedule.type === "once") { + const runAt = DateTime.make(schedule.runAt); + return Option.isSome(runAt) && + DateTime.toEpochMillis(runAt.value) > DateTime.toEpochMillis(from) + ? runAt.value + : null; + } if (schedule.type === "interval") { // Intervals advance elapsed time, including across daylight-saving changes. return DateTime.addDuration(from, Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS)); @@ -48,6 +56,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul return b.type === "interval" && a.everyMs === b.everyMs; } if (a.type === "webhook") return b.type === "webhook"; + if (a.type === "once") return b.type === "once" && Date.parse(a.runAt) === Date.parse(b.runAt); if (b.type !== "fixed_time") return false; // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts index 4a1a58505c5cb..1582af292d4e8 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts @@ -4,6 +4,7 @@ import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import { afterEach, vi } from "vite-plus/test"; @@ -129,3 +130,254 @@ it.effect.each([ Effect.provide(SqlitePersistence.layerMemory), ), ); + +// Each launch takes two minutes, so a manual run can start before the run time and end after it. +const slowLaunchLayer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.merge( + dependencies, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => TestClock.adjust("2 minutes").pipe(Effect.andThen(Effect.die("synthetic"))), + }), + ), + ), +); + +it.effect("runs a one-time schedule once and rejects past times", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const decoded = yield* decodeInput({ + ...input, + schedule: { type: "once", runAt: "2026-11-01T03:00:00-05:00" }, + }); + const created = yield* service.upsert(decoded); + expect(created.task.schedule).toEqual({ type: "once", runAt: "2026-11-01T08:00:00.000Z" }); + expect(created.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + + // A manual run that starts before the time keeps the scheduled run, even if it ends after. + const early = yield* service.runNow({ id: created.task.id }); + expect(early.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + + const due = yield* service.runNow({ id: created.task.id }); + expect(due.task.nextRunAt).toBeNull(); + expect((yield* service.list()).tasks[0]?.nextRunAt).toBeNull(); + + yield* service.setEnabled({ id: created.task.id, enabled: false }); + const resumed = yield* service + .setEnabled({ id: created.task.id, enabled: true }) + .pipe(Effect.flip); + expect(resumed.message).toBe("The run time has passed. Pick a time in the future."); + + const past = yield* service + .upsert({ ...decoded, commandId: undefined, title: "Late" }) + .pipe(Effect.flip); + expect(past.message).toBe("The run time has passed. Pick a time in the future."); + }).pipe(Effect.provide(slowLaunchLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +const ONCE = { type: "once", runAt: "2026-11-01T08:00:00Z" } as const; +/** A next run that only the simulated concurrent write produces. */ +const MOVED = "2030-01-01T00:00:00.000Z"; + +const onceInput = { + ...input, + commandId: "once-race", + schedule: { type: "once", runAt: "2026-11-01T08:00:00Z" }, +}; + +it.effect.each([ + { change: "a run completes", saved: ONCE, edited: ONCE, pause: false, expected: MOVED }, + { + change: "a run completes during a padded-time edit", + saved: { type: "fixed_time", timeOfDay: "9:00" }, + edited: { type: "fixed_time", timeOfDay: "09:00" }, + pause: false, + expected: MOVED, + }, + { + change: "another client pauses the task", + saved: ONCE, + edited: ONCE, + pause: true, + expected: "2026-11-01T08:00:00.000Z", + }, +] as const)( + "a title save keeps the stored next run only while nothing else changed: $change", + ({ saved, edited, pause, expected }) => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z")); + // The project check runs between the save's read and write, so it stands in for + // a concurrent write in that gap. + const writeMidSave = Layer.unwrap( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => + sql`UPDATE scheduled_tasks SET next_run_at = ${MOVED}, enabled = ${pause ? 0 : 1}`.pipe( + Effect.as(false), + Effect.orDie, + ), + }); + }), + ); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, writeMidSave)), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const worktree = { type: "worktree", baseRef: "main" }; + yield* service.upsert( + yield* decodeInput({ + ...onceInput, + schedule: saved, + workspaceStrategy: { type: "root" }, + }), + ); + const renamed = yield* service.upsert( + yield* decodeInput({ + ...onceInput, + schedule: edited, + workspaceStrategy: worktree, + title: "Renamed", + }), + ); + expect(renamed.task.enabled).toBe(true); + expect(renamed.task.nextRunAt).toBe(expected); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("keeps a one-time run after a restart interrupts an earlier manual run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z")); + const sql = yield* SqlClient.SqlClient; + const created = yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + return yield* service.upsert(yield* decodeInput(onceInput)); + }).pipe(Effect.provide(serviceLayer)); + yield* sql`UPDATE scheduled_tasks SET last_run_status = 'running', last_run_at = '2026-11-01T07:59:00.000Z'`; + + yield* TestClock.setTime(Date.parse("2026-11-01T08:05:00.000Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const [task] = (yield* service.list()).tasks; + expect(task?.id).toBe(created.task.id); + expect(task?.lastRunStatus).toBe("failed"); + expect(task?.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + }).pipe(Effect.provide(serviceLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("reports the stored task when the row was replaced during the run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z")); + // The launch stands in for a delete and same-id recreate while the run is out. + const replaceRowDuringLaunch = Layer.unwrap( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL, run_count = 0`.pipe( + Effect.orDie, + Effect.andThen(Effect.die("synthetic dispatch failure")), + ), + }); + }), + ); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, replaceRowDuringLaunch)), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const created = yield* service.upsert(yield* decodeInput(onceInput)); + const ran = yield* service.runNow({ id: created.task.id }); + expect(ran.task.runCount).toBe(0); + expect(ran.task.lastRunStatus).toBe("never"); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("catches up one enabled one-time run after downtime", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + let tick = Effect.void; + const capture = Layer.succeed(Scheduler.Scheduler, { + register: (name, run) => + Effect.context>().pipe( + Effect.map((context) => { + if (name === "scheduled-tasks") + tick = run.pipe(Effect.provideContext(context), Effect.orDie); + }), + ), + }); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, capture)), + ); + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00Z")); + const id = yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const create = (commandId: string) => + decodeInput({ ...onceInput, commandId }).pipe( + Effect.flatMap(service.upsert), + Effect.map(({ task }) => task.id), + ); + const due = yield* create("due"); + const paused = yield* create("paused"); + const deleted = yield* create("deleted"); + const legacy = yield* create("legacy-interval"); + yield* sql`UPDATE scheduled_tasks + SET schedule_json = '{"type":"interval","everyMs":3600000}', + next_run_at = '2026-11-03T10:00:00.000Z' + WHERE task_id = ${legacy}`; + yield* service.setEnabled({ id: due, enabled: false }); + expect((yield* service.setEnabled({ id: due, enabled: true })).task.nextRunAt).toBe( + "2026-11-01T08:00:00.000Z", + ); + yield* service.setEnabled({ id: paused, enabled: false }); + yield* service.delete({ id: deleted }); + return due; + }).pipe(Effect.provide(layer)); + + yield* TestClock.setTime(Date.parse("2026-11-03T09:00:00Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + yield* tick; + yield* tick; + const { tasks } = yield* service.list(); + expect(tasks).toHaveLength(3); + expect(tasks.find((task) => task.id === id)).toMatchObject({ + lastRunAt: "2026-11-03T09:00:00.000Z", + lastRunStatus: "failed", + nextRunAt: null, + runCount: 1, + }); + expect(tasks.find((task) => !task.enabled)).toMatchObject({ + enabled: false, + nextRunAt: null, + runCount: 0, + }); + expect(tasks.find((task) => task.schedule.type === "interval")).toMatchObject({ + schedule: { type: "interval", everyMs: 3_600_000 }, + nextRunAt: "2026-11-03T10:00:00.000Z", + runCount: 0, + }); + expect(yield* sql`SELECT SUM(run_count) AS runs FROM scheduled_tasks`).toEqual([{ runs: 1 }]); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 2566960406c96..da39d01b42cf0 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -301,6 +301,35 @@ function nextRunAt( } } +/** + * Next run after an interrupted run. A one-time task counts from the run's start + * (`lastRunAt`), so a manual run that began before its time does not use it up. + */ +function nextRunAfterRun( + task: Pick, + now: DateTime.DateTime, +): string | null { + const startedAt = + task.schedule.type === "once" && task.lastRunAt !== null + ? DateTime.make(task.lastRunAt) + : Option.none(); + return nextRunAt( + task, + Option.getOrElse(startedAt, () => now), + ); +} + +/** An enabled one-time task with a past time would never run, so saving it is an error. */ +function requireFutureOnce( + task: Pick, + now: DateTime.DateTime, + taskId: ScheduledTaskId, +) { + return task.enabled && task.schedule.type === "once" && nextRunAt(task, now) === null + ? taskError("The run time has passed. Pick a time in the future.", { taskId }) + : Effect.void; +} + function errorMessage(error: unknown): string { if (Cause.isCause(error)) return Cause.pretty(error); if (error instanceof Error) return error.message; @@ -477,7 +506,10 @@ export const layer = Layer.effect( for (const row of rows) { const decodedType = decodeScheduleType(row.schedule_json); const type = Option.isSome(decodedType) ? decodedType.value.type : undefined; - if (typeof type === "string" && !["interval", "fixed_time", "webhook"].includes(type)) { + if ( + typeof type === "string" && + !["interval", "fixed_time", "once", "webhook"].includes(type) + ) { unsupported++; } else { supported.push(row); @@ -561,6 +593,8 @@ export const layer = Layer.effect( const saveTask = ( task: ScheduledTask, requireExisting: boolean, + /** The schedule this save read, when it left the schedule unchanged. */ + keptSchedule: ScheduledTask["schedule"] | null, webhook: { readonly token: string | null; readonly secret: string | null; @@ -631,7 +665,18 @@ export const layer = Layer.effect( interaction_mode = excluded.interaction_mode, creation_source = excluded.creation_source, updated_at = excluded.updated_at, - next_run_at = excluded.next_run_at, + -- Keep a next_run_at that a finishing run moved during this save, unless + -- another edit changed the schedule or enabled state meanwhile. + next_run_at = CASE + WHEN ${keptSchedule === null ? 0 : 1} = 1 + AND scheduled_tasks.schedule_json IN ( + excluded.schedule_json, + ${keptSchedule === null ? null : JSON.stringify(keptSchedule)} + ) + AND scheduled_tasks.enabled = excluded.enabled + THEN scheduled_tasks.next_run_at + ELSE excluded.next_run_at + END, -- Only rotate changes a live token, so a save racing a rotation -- cannot bring the old URL back. webhook_token = CASE @@ -704,7 +749,9 @@ export const layer = Layer.effect( WHERE task_id = ${input.id} AND last_run_status = 'running' AND last_run_at = ${input.startedAtIso} + RETURNING task_id `.pipe( + Effect.map((rows) => rows.length > 0), Effect.mapError((cause) => taskError("Could not record schedule task run.", { taskId: input.id, cause }), ), @@ -714,7 +761,12 @@ export const layer = Layer.effect( // runDueTasks skips 'running' rows forever, and the dispatch may already have gone out, // so next_run_at must advance. Pending webhook attempts count only with their completion. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( - function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { + function* ( + task: ScheduledTask, + startedAtIso: string, + message: string, + pendingDeliveryId?: string, + ) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -723,17 +775,17 @@ export const layer = Layer.effect( UPDATE scheduled_tasks SET last_run_status = 'failed', last_run_error = ${message}, - next_run_at = ${nextRunAt(source, now)}, + next_run_at = ${nextRunAfterRun({ ...source, lastRunAt: startedAtIso }, now)}, updated_at = ${iso(now)}, run_count = run_count + CASE WHEN EXISTS ( SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${pendingDeliveryId ?? null} AND state = 'running' ) THEN 0 ELSE 1 END - WHERE task_id = ${task.id} AND last_run_status = 'running' + WHERE task_id = ${task.id} AND last_run_status = 'running' AND last_run_at = ${startedAtIso} `; yield* notifyChanged; }, - (effect, task, _message, _pendingDeliveryId) => + (effect, task, _startedAtIso, _message, _pendingDeliveryId) => effect.pipe( Effect.catch((cause) => Effect.logWarning("Could not release stuck schedule task run", { @@ -753,6 +805,9 @@ export const layer = Layer.effect( readonly dispatched: Ref.Ref; }, ) { + // Read before reserving: nothing between the reservation and its release may fail. + const startedAt = yield* localNow; + const startedAtIso = iso(startedAt); const reserved = yield* Ref.modify(activeRuns, (active) => { if (active.has(task.id)) return [false, active] as const; const next = new Set(active); @@ -763,13 +818,10 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task is already running.", { taskId: task.id }); } - return { task, threadId: null }; + return { task, threadId: null, dispatchFailed: false }; } return yield* Effect.gen(function* () { - const startedAt = yield* localNow; - const startedAtIso = iso(startedAt); - // Re-read stale poll snapshots so deleted, paused or postponed tasks cannot fire. const active = yield* findTask(task.id); if (active === null) { @@ -781,7 +833,7 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task not found.", { taskId: task.id }); } - return { task, threadId: null }; + return { task, threadId: null, dispatchFailed: false }; } if ( webhook !== undefined && @@ -789,7 +841,11 @@ export const layer = Layer.effect( NodeSqliteClient.readOnly, )).length > 0 ) - return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; + return { + task: { ...active, lastRunStatus: "succeeded" as const }, + threadId: null, + dispatchFailed: false, + }; const parsedNextRunAt = active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); if ( @@ -798,7 +854,7 @@ export const layer = Layer.effect( Option.isNone(parsedNextRunAt) || DateTime.toEpochMillis(parsedNextRunAt.value) > DateTime.toEpochMillis(startedAt)) ) { - return { task: active, threadId: null }; + return { task: active, threadId: null, dispatchFailed: false }; } // Reject queued deliveries after pause, same-id recreation or trigger changes. if (webhook !== undefined) { @@ -914,18 +970,28 @@ export const layer = Layer.effect( const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); const lastRunError = runSucceeded ? null : errorMessage(result.cause); const threadId = runSucceeded ? result.value : null; - const current = yield* findTask(task.id); - const scheduleSource = current ?? task; - const completed: ScheduledTask = { - ...scheduleSource, - updatedAt: iso(completedAt), - lastRunAt: startedAtIso, - nextRunAt: nextRunAt(scheduleSource, completedAt), - lastRunStatus, - lastRunError, - runCount: scheduleSource.runCount + 1, - }; - if (current !== null) { + // One transaction, so an edit cannot land between reading the schedule and + // writing the next run computed from it. The origin is read first: it reads + // files, and the transaction holds SQLite's writer lock. + const origin = yield* readWebhookOrigin; + const record = Effect.gen(function* () { + const [row] = yield* getRows(task.id); + const current = row === undefined ? null : yield* decodeRow(row, origin); + const scheduleSource = current ?? task; + const completed: ScheduledTask = { + ...scheduleSource, + updatedAt: iso(completedAt), + lastRunAt: startedAtIso, + // A manual run that starts before a one-time run must not use it up. + nextRunAt: nextRunAt( + scheduleSource, + scheduleSource.schedule.type === "once" ? startedAt : completedAt, + ), + lastRunStatus, + lastRunError, + runCount: scheduleSource.runCount + 1, + }; + if (current === null) return { recorded: completed, written: false }; // startedAtIso in the guard ensures this writes only to the row this // run marked as running — a task deleted mid-run and recreated with // the same id (idempotent commandId replay) must not be stamped. @@ -937,32 +1003,43 @@ export const layer = Layer.effect( error: lastRunError, startedAtIso, }); - if (webhook === undefined) yield* completion; - else - yield* sql - .withTransaction( - Effect.gen(function* () { - const pending = - yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; - if (pending.length === 0) yield* completion; - else { - const counted = - yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} - WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; - if (counted.length > 0) yield* completion; - } - }), - ) - .pipe( - Effect.mapError((cause) => - taskError("Could not record webhook completion.", { taskId: task.id, cause }), - ), - ); - yield* notifyChanged; - } - return { task: completed, threadId }; + let written = false; + if (webhook === undefined) written = yield* completion; + else { + const pending = + yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; + if (pending.length === 0) written = yield* completion; + else { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} + WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; + if (counted.length > 0) written = yield* completion; + } + } + // A row recreated mid-run is not this run's, so report what is stored. + return { recorded: written ? completed : current, written }; + }); + const { recorded, written } = yield* sql + .withTransaction(record) + .pipe( + Effect.mapError((cause) => + isScheduledTaskError(cause) + ? cause + : taskError( + webhook === undefined + ? "Could not record schedule task run." + : "Could not record webhook completion.", + { taskId: task.id, cause }, + ), + ), + ); + if (written) yield* notifyChanged; + // The stored row can belong to a replacement, so report this dispatch separately. + return { task: recorded, threadId, dispatchFailed: !runSucceeded }; }).pipe( - Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause), webhook?.deliveryId)), + Effect.onError((cause) => + releaseStuckRun(task, startedAtIso, errorMessage(cause), webhook?.deliveryId), + ), Effect.ensuring( Ref.update(activeRuns, (active) => { const next = new Set(active); @@ -1058,7 +1135,7 @@ export const layer = Layer.effect( UPDATE scheduled_tasks SET last_run_status = 'failed', last_run_error = 'Run was interrupted by a server restart.', - next_run_at = ${nextRunAt(decoded.success, now)}, + next_run_at = ${nextRunAfterRun(decoded.success, now)}, updated_at = ${iso(now)}, run_count = run_count + CASE WHEN EXISTS ( SELECT 1 FROM scheduled_task_webhook_pending @@ -1169,7 +1246,9 @@ export const layer = Layer.effect( : undefined), ), } - : input.schedule; + : input.schedule.type === "once" + ? { type: "once", runAt: iso(DateTime.makeUnsafe(input.schedule.runAt)) } + : input.schedule; const webhook = input.schedule.type === "webhook" ? yield* Effect.gen(function* () { @@ -1215,6 +1294,8 @@ export const layer = Layer.effect( existingTask !== null && existingTask.enabled === input.enabled && isSameSchedule(existingTask.schedule, schedule); + if (!scheduleUnchanged) + yield* requireFutureOnce({ enabled: input.enabled, schedule }, now, id); const task: ScheduledTask = { id, title: input.title, @@ -1239,7 +1320,12 @@ export const layer = Layer.effect( lastRunError: existingTask?.lastRunError ?? null, runCount: existingTask?.runCount ?? 0, }; - yield* saveTask(task, input.requireExisting === true, webhook); + yield* saveTask( + task, + input.requireExisting === true, + scheduleUnchanged ? existingTask.schedule : null, + webhook, + ); yield* notifyChanged; return { task: (yield* findTask(id)) ?? task }; }); @@ -1250,6 +1336,11 @@ export const layer = Layer.effect( const existing = yield* loadTask(input.id); if (existing.enabled === input.enabled) return { task: existing }; const now = yield* localNow; + yield* requireFutureOnce( + { enabled: input.enabled, schedule: existing.schedule }, + now, + input.id, + ); const next = nextRunAt({ enabled: input.enabled, schedule: existing.schedule }, now); // RETURNING so a task deleted between the load and this UPDATE is a // visible not-found error, not a false success. @@ -1687,8 +1778,8 @@ export const layer = Layer.effect( ]); const dispatched = yield* Ref.make(false); return yield* runTask(task, "webhook", { deliveryId, prompt, dispatched }).pipe( - Effect.flatMap(({ task: completed }) => - completed.lastRunStatus === "failed" + Effect.flatMap(({ dispatchFailed }) => + dispatchFailed ? runOutcome("failed").pipe( Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), Effect.andThen(releaseFailedDelivery(deliveryId)), diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index cdd4e60e62d76..3bc3fe506e78f 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -1199,6 +1199,32 @@ it.effect("start failures release both replay keys and relay claims for redelive ), ); +it.effect("a start failure releases the relay claim when the task row is replaced mid-run", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + const request = requestFor(task, { relayDeliveryId: "replaced-mid-run" }); + const first = yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ).pipe(Effect.forkChild); + yield* Queue.take(launches); + // A same-id recreate while the launch is out: the stored row is no longer this run's. + yield* sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL`; + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(first); + const retry = yield* service.triggerWebhook(request); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + }), + { gate, failLaunches: { remaining: 1 } }, + ); + }), +); + it.effect("main retention keeps fifty runs and preserves filtered and dedupe buckets", () => withService(({ service, sql, launches }) => Effect.gen(function* () { diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index 7e42bba4bc6c8..ca8192dd894e3 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -70,7 +70,9 @@ function environment() { environmentId, label: "Laptop", connection: { phase: state.connected ? "connected" : "disconnected" }, - serverConfig: state.connected ? { environment: { platform: "linux" } } : null, + serverConfig: state.connected + ? { environment: { platform: "linux", capabilities: { oneTimeSchedules: true } } } + : null, }; } diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index c5043f51d7c26..a7540862bbab1 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -78,7 +78,8 @@ import { SettingsGroup } from "./SettingsGroup"; import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; import { WEBHOOK_SIGNATURE_DEFAULTS, - webhookDraftErrors, + scheduleDraftErrors, + toDateTimeLocalValue, webhookDeliveryLabel, defaultScheduledTaskProjectId, matchesScheduledTaskScope, @@ -137,6 +138,8 @@ const EMPTY_DRAFT: DraftState = { scheduleMode: "fixed", intervalMinutes: "15", timeOfDay: "09:00", + runAt: "", + savedRunAt: "", weekdays: new Set([1, 2, 3, 4, 5]), projectId: "", threadId: "", @@ -285,8 +288,14 @@ function splitModelKey(value: string): ModelSelection | null { }; } +const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + export function scheduleLabel(schedule: ScheduledTaskSchedule): string { if (schedule.type === "webhook") return "On webhook"; + if (schedule.type === "once") return `Once, ${ONCE_FORMATTER.format(new Date(schedule.runAt))}`; if (schedule.type === "interval") { const minutes = schedule.everyMs / 60_000; return Number.isInteger(minutes) @@ -1172,6 +1181,10 @@ function ScheduledTaskEditorForm({ const { scope, connectedEnvironments } = useSettingsScope(); const [environmentId, setEnvironmentId] = useState(initialEnvironmentId); const environment = useEnvironment(environmentId); + // Older servers reject one-time schedules, so offer Once only where it can save. + const offersOnce = + environment?.serverConfig?.environment.capabilities.oneTimeSchedules === true || + task?.schedule.type === "once"; const connected = environment?.connection.phase === "connected" && environment.serverConfig !== null; const tasksQuery = useEnvironmentQuery( @@ -1279,7 +1292,7 @@ function ScheduledTaskEditorForm({ } const schedule = scheduleFromDraft(draft); if (schedule === null) { - for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); + for (const error of scheduleDraftErrors(draft)) reportFailure(error.title, error.description); return; } if ( @@ -1299,6 +1312,13 @@ function ScheduledTaskEditorForm({ reportFailure("Invalid interval", "Enter an interval of at least one minute."); return; } + if (schedule.type === "once" && !offersOnce) { + reportFailure( + "Once is not available here", + "This environment runs an older T3 Code. Update it to schedule a one-time task, or pick another schedule.", + ); + return; + } if (draft.workspaceMode === "existing_worktree" && !draft.existingWorktreePath.trim()) { reportFailure("Checkout path is required", "Enter the path of the checkout to run in."); return; @@ -1353,7 +1373,8 @@ function ScheduledTaskEditorForm({ {draft.editingId ? "Edit task" : "New task"} - Run a prompt automatically on an interval, at a fixed time, or when a webhook is called. + Run a prompt automatically on an interval, at a fixed time, once at a set date, or when a + webhook is called. @@ -1581,18 +1602,39 @@ function ScheduledTaskEditorForm({ value={[draft.scheduleMode]} onValueChange={(values) => { const mode = values[0] as ScheduleMode | undefined; - if (mode === "fixed" || mode === "interval" || mode === "webhook") - setDraft((current) => ({ - ...current, - scheduleMode: mode, - prompt: - mode === "webhook" && !current.prompt.trim() - ? DEFAULT_WEBHOOK_PROMPT - : current.prompt, - })); + if ( + mode === "fixed" || + mode === "once" || + mode === "interval" || + mode === "webhook" + ) + setDraft((current) => { + const suggested = + mode === "once" && !current.runAt + ? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000) + : null; + return { + ...current, + scheduleMode: mode, + // Keep the instant too, so an hour that repeats when clocks go back stays exact. + ...(suggested === null + ? {} + : { + runAt: toDateTimeLocalValue(suggested), + savedRunAt: suggested.toISOString(), + }), + prompt: + mode === "webhook" && !current.prompt.trim() + ? DEFAULT_WEBHOOK_PROMPT + : current.prompt, + }; + }); }} > At a time + {offersOnce || draft.scheduleMode === "once" ? ( + Once + ) : null} Every interval On webhook @@ -1768,6 +1810,20 @@ function ScheduledTaskEditorForm({ ) : null} + ) : draft.scheduleMode === "once" ? ( +
    + + + setDraft((current) => ({ ...current, runAt: event.target.value })) + } + /> +
    ) : draft.scheduleMode === "fixed" ? (
    diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index a2540f8310cac..a8ac6ee160906 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -7,7 +7,7 @@ import { ScheduledTaskId, type ScheduledTask, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { SidebarProjectGroupMember, @@ -176,6 +176,42 @@ describe("editing scheduled task branch settings", () => { }); }); +describe("one-time scheduled tasks", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("round-trips the run time through the local date-time input", () => { + const runAt = "2026-11-03T17:00:42.000Z"; + const draft = taskToDraft({ ...legacyTask, schedule: { type: "once", runAt } }); + expect(draft.scheduleMode).toBe("once"); + expect(scheduleFromDraft(draft)).toEqual({ type: "once", runAt }); + const moved = scheduleFromDraft({ ...draft, runAt: "2026-11-04T09:30" }); + expect(moved).toEqual({ type: "once", runAt: new Date("2026-11-04T09:30").toISOString() }); + expect(scheduleFromDraft({ ...draft, runAt: "" })).toBeNull(); + }); + + it("rejects a time skipped by daylight saving", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const draft = taskToDraft({ + ...legacyTask, + schedule: { type: "once", runAt: "2027-03-14T18:00:00.000Z" }, + }); + expect(scheduleFromDraft({ ...draft, runAt: "2027-03-14T02:30" })).toBeNull(); + }); + + it("keeps the saved offset when an edit stays in an hour that repeats", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + // 01:30 PST, the second 01:30 on 2026-11-01. + const draft = taskToDraft({ + ...legacyTask, + schedule: { type: "once", runAt: "2026-11-01T09:30:00.000Z" }, + }); + expect(scheduleFromDraft({ ...draft, runAt: "2026-11-01T01:35" })).toEqual({ + type: "once", + runAt: "2026-11-01T09:35:00.000Z", + }); + }); +}); + describe("webhook scheduled tasks", () => { const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const; const webhookTask: ScheduledTask = { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 27ddc59f84c7d..365bd09e041ad 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -52,7 +52,7 @@ export function validateScheduledTasksSearch(raw: Record) { }; } -export type ScheduleMode = "fixed" | "interval" | "webhook"; +export type ScheduleMode = "fixed" | "once" | "interval" | "webhook"; export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; export interface DraftState { @@ -63,6 +63,10 @@ export interface DraftState { readonly scheduleMode: ScheduleMode; readonly intervalMinutes: string; readonly timeOfDay: string; + /** Local date and time as a `datetime-local` input value, such as 2026-11-02T15:00. */ + readonly runAt: string; + /** The stored ISO run time, sent back unchanged when the field was not edited. */ + readonly savedRunAt: string; readonly weekdays: ReadonlySet; readonly projectId: string; readonly threadId: string; @@ -96,7 +100,24 @@ export const WEBHOOK_SIGNATURE_DEFAULTS = { signaturePrefix: "sha256=", } as const; -export function webhookDraftErrors(draft: DraftState) { +/** Formats a date as a `datetime-local` input value in the browser's time zone. */ +export function toDateTimeLocalValue(date: Date): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +export function scheduleDraftErrors(draft: DraftState) { + if (draft.scheduleMode === "once") { + const runAt = new Date(draft.runAt); + if (Number.isNaN(runAt.getTime())) + return [{ title: "Invalid run time", description: "Pick a date and time." }]; + // A time skipped by a daylight saving change parses to a different clock time. + if (toDateTimeLocalValue(runAt) !== draft.runAt) + return [ + { title: "Invalid run time", description: "That time does not exist in your time zone." }, + ]; + return []; + } if (draft.scheduleMode !== "webhook") return []; if (parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes) === undefined) return [ @@ -144,8 +165,26 @@ export function webhookDeliveryLabel(delivery: { } export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule | null { + if (scheduleDraftErrors(draft).length > 0) return null; + if (draft.scheduleMode === "once") { + const saved = draft.savedRunAt ? new Date(draft.savedRunAt) : null; + if (saved !== null && toDateTimeLocalValue(saved) === draft.runAt) + return { type: "once", runAt: draft.savedRunAt }; + // A `datetime-local` value has no offset. In an hour that repeats when clocks go + // back, keep the saved time's offset; otherwise Date reads it as local time. + const withSavedOffset = + saved === null + ? null + : new Date(Date.parse(`${draft.runAt}Z`) + saved.getTimezoneOffset() * 60_000); + return { + type: "once", + runAt: + withSavedOffset !== null && toDateTimeLocalValue(withSavedOffset) === draft.runAt + ? withSavedOffset.toISOString() + : new Date(draft.runAt).toISOString(), + }; + } if (draft.scheduleMode === "webhook") { - if (webhookDraftErrors(draft).length > 0) return null; const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); const replayKey = draft.replayKey.trim(); const coalesceKey = draft.coalesceKey.trim(); @@ -206,11 +245,12 @@ export function taskToDraft(task: ScheduledTask): DraftState { title: task.title, prompt: task.prompt, enabled: task.enabled, - scheduleMode: - schedule.type === "interval" ? "interval" : schedule.type === "webhook" ? "webhook" : "fixed", + scheduleMode: schedule.type === "fixed_time" ? "fixed" : schedule.type, intervalMinutes: schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15", timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00", + runAt: schedule.type === "once" ? toDateTimeLocalValue(new Date(schedule.runAt)) : "", + savedRunAt: schedule.type === "once" ? schedule.runAt : "", weekdays, projectId: task.projectId, threadId: task.threadId ?? "", diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 4db105440f0e1..d24f91be7bae1 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -113,11 +113,15 @@ branch name remains. ## Scheduled tasks on mobile -Open **Settings → Scheduled tasks** to create recurring tasks or manage existing +Open **Settings → Scheduled tasks** to create scheduled tasks or manage existing ones across your connected environments. Use the settings filter to narrow the list by environment or project. Each task runs on the environment you choose, using its project, model, and workspace settings. Fixed-time schedules use that -environment's time zone, which may differ from your phone's. +environment's time zone, which may differ from your phone's. Choose **Once** +to run a task a single time at a date and time in your own time zone. If the +environment is offline then, an enabled task runs once when it is back. Pause or +delete the task to cancel its upcoming run. To re-enable a paused task after its +time has passed, first choose a new time. Deleting a thread also deletes tasks that post into that thread. Tasks that start a new thread on each run survive deletion of their previous threads. diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 378dc9e45760a..5e7bc3806fabf 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -21,9 +21,10 @@ {"id": "stack-links-every-layer", "area": "pull-requests", "kind": "guard", "pattern": "stacked PRs: every layer ends up linked", "experience": "Must keep working: for a 2-PR stack, both layers end up linked, not only the top.", "rule": "For a stack, call it for every layer, not just the current branch or the top PR.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. Branch feature/error-detail (on main) and branch feature/frozen-host (on top of feature/error-detail) are committed locally. Nothing is pushed yet.", "mocks": {"link_pull_request": "default"}, "world": {"branch": "feature/frozen-host", "branches": {"feature/error-detail": "e1d2c3b"}, "commits": [{"sha": "f7e6d5c", "subject": "feat(server): detect a frozen host and say so"}, {"sha": "e1d2c3b", "subject": "feat(server): include the failing step in connection errors"}], "nextPullRequests": "The next pull request created on acme/app is #124, and the one after it is #125."}, "shellMocks": [{"match": "gh pr create.*(--base|-B)[= ]['\"]?main\\b", "output": "https://github.com/acme/app/pull/124"}, {"match": "gh pr create.*(--base|-B)[= ]['\"]?feature/error-detail\\b", "output": "https://github.com/acme/app/pull/125"}]}, "prompt": "Open both as stacked PRs: feature/error-detail against main, and feature/frozen-host on top of it.", "expect": {"linked": ["~/pull/124", "~/pull/125"]}, "notes": "No real 'only the top layer linked' miss exists after v2 (checked all stacked creates since Oct 2). The real run used watch because of 'Full stop'. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 10} {"id": "takeover-links-pr", "area": "pull-requests", "kind": "guard", "pattern": "takeover of a pasted PR links it", "experience": "Must keep working: on 'take over this PR and get it in', link the pasted PR before working on it.", "rule": "register every pull request you create or work on for this thread", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "context": "This worktree has the PR's branch fix/config-snapshot checked out.", "world": {"branch": "fix/config-snapshot", "commits": [{"sha": "abc1234", "subject": "fix(server): stream config snapshots so slow ones keep the socket alive"}], "diff": "diff --git a/apps/server/src/config/snapshot.ts b/apps/server/src/config/snapshot.ts\n--- a/apps/server/src/config/snapshot.ts\n+++ b/apps/server/src/config/snapshot.ts\n@@ -1,6 +1,8 @@\n-import { buildSnapshot, streamSnapshot } from './build';\n+import { streamSnapshot } from './build';\n \n export async function sendSnapshot(socket: Socket, config: Config) {\n- const snapshot = await buildSnapshot(config);\n- socket.send(snapshot);\n+ // Send in chunks so a slow snapshot never blocks the heartbeat.\n+ for await (const chunk of streamSnapshot(config)) {\n+ socket.send(chunk);\n+ }\n }\n", "files": {"apps/server/src/config/snapshot.ts": "import { streamSnapshot } from './build';\n\nexport async function sendSnapshot(socket: Socket, config: Config) {\n // Send in chunks so a slow snapshot never blocks the heartbeat.\n for await (const chunk of streamSnapshot(config)) {\n socket.send(chunk);\n }\n}\n", "apps/server/src/config/build.ts": "export async function buildSnapshot(config: Config) {\n return JSON.stringify(config);\n}\n\nexport async function* streamSnapshot(config: Config) {\n for (const [key, value] of Object.entries(config)) yield JSON.stringify({ key, value });\n}\n"}, "prs": [{"number": 123, "title": "fix(server): slow config snapshots no longer drop connections", "headRefName": "fix/config-snapshot", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Check", "state": "pending"}, {"name": "Test Web", "state": "pending"}], "author": {"login": "contributor"}}]}}, "prompt": "https://github.com/acme/app/pull/123\n\nThis looks legit. Take over and get it in.", "expect": {"calls": [{"tool": "link_pull_request|watch_pull_request", "args": {"url": "~/pull/123"}}]}, "notes": "A run that calls watch_pull_request first (which also links) would fail this check. If the runner treats watch as a link, relax it.", "maxSteps": 14} {"id": "handback-unwatches", "area": "pull-requests", "kind": "guard", "pattern": "unwatch before handing back", "experience": "Must keep working: when the babysit goal is met and the user merges, call unwatch_pull_request before the hand-back reply.", "rule": "When monitoring is done and you hand the work back to the user, call unwatch_pull_request first", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Make CI setup survive npm registry outages, then open a PR. You are done when CI on the PR is green. I will merge it myself."}, {"role": "assistant", "text": "Opened [#124](https://github.com/acme/app/pull/124). T3 Code is watching it; I will report when CI is green.", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/.github/workflows/ci.yml", "old_string": " - run: pnpm install", "new_string": " - run: pnpm install || pnpm install --registry https://registry.npmmirror.com"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/.github/workflows/ci.yml has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"ci: fall back to the registry mirror when npm is down\""}, "result": "[ci/npm-outage abc1234] ci: fall back to the registry mirror when npm is down\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin ci/npm-outage"}, "result": "To github.com:acme/app.git\n * [new branch] ci/npm-outage -> ci/npm-outage\nbranch 'ci/npm-outage' set up to track 'origin/ci/npm-outage'."}, {"tool": "Bash", "input": {"command": "gh pr create --base main --title \"ci: fall back to the registry mirror when npm is down\" --fill"}, "result": "https://github.com/acme/app/pull/124"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/124"}, "result": {"host": "github.com", "repository": "acme/app", "number": 124, "url": "https://github.com/acme/app/pull/124", "alreadyLinked": false}}, {"tool": "watch_pull_request", "input": {"url": "https://github.com/acme/app/pull/124"}, "result": {"host": "github.com", "repository": "acme/app", "number": 124, "url": "https://github.com/acme/app/pull/124", "watching": true, "wasWatching": false}}]}], "mocks": {"unwatch_pull_request": "default"}, "world": {"branch": "ci/npm-outage", "commits": [{"sha": "abc1234", "subject": "ci: fall back to the registry mirror when npm is down"}], "diff": "diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n--- a/.github/workflows/ci.yml\n+++ b/.github/workflows/ci.yml\n@@ -8,1 +8,1 @@\n- - run: pnpm install\n+ - run: pnpm install || pnpm install --registry https://registry.npmmirror.com\n", "prs": [{"number": 124, "title": "ci: fall back to the registry mirror when npm is down", "headRefName": "ci/npm-outage", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}]}]}}, "prompt": "Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- All 12 checks passed on abc1234.\n\nLook 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.", "expect": {"calls": [{"tool": "unwatch_pull_request", "args": {"url": "~/pull/124"}}], "notCalls": ["watch_pull_request"], "shellMustNot": ["gh pr merge"]}, "notes": "Unwatch was followed 7 of 7 times after the fixes. No real 'forgot to unwatch' miss was found, so this is a guard, not a bad case.", "maxSteps": 7} -{"id": "schedule-one-off-tomorrow", "area": "pull-requests", "kind": "guard", "pattern": "schedule asked: schedule_task, not the job", "experience": "Must keep working: a schedule ask creates a schedule_task with an object schedule and reports the next run, without running the check now.", "rule": "schedule_task creates persistent recurring work in the app scheduler. Pass schedule as a structured object / After scheduling a timer, report the returned cadence and next run time", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Check app for memory leaks", "schedule": {"type": "fixed_time", "timeOfDay": "23:00", "weekdays": [0]}, "enabled": true, "bindToCurrentThread": true, "nextRunAt": "2026-10-25T23:00:00-07:00"}}, "shellMocks": [{"match": "\\bdate\\b", "output": "Saturday 2026-10-24 10:00 PDT"}]}, "prompt": "Definitely want to schedule something to check the app for memory leaks at some point tomorrow, maybe like 11pm?", "expect": {"calls": [{"tool": "schedule_task"}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "textMust": ["(11(:00)? ?[Pp]\\.?[Mm]|23:00)"]}, "notes": "No one-shot schedule type exists, so a weekly fixed_time plus a later delete is the accepted pattern."} +{"id": "schedule-one-off-tomorrow", "area": "pull-requests", "kind": "guard", "pattern": "schedule asked: schedule_task, not the job", "experience": "Must keep working: a schedule ask creates a schedule_task with an object schedule and reports the next run, without running the check now.", "rule": "schedule_task creates persistent recurring work in the app scheduler. Pass schedule as a structured object / After scheduling a timer, report the returned cadence and next run time", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Check app for memory leaks", "schedule": {"type": "once", "runAt": "2026-10-25T23:00:00-07:00"}, "enabled": true, "bindToCurrentThread": true, "nextRunAt": "2026-10-25T23:00:00-07:00"}}, "shellMocks": [{"match": "\\bdate\\b", "output": "Saturday 2026-10-24 10:00 PDT"}]}, "prompt": "Definitely want to schedule something to check the app for memory leaks at some point tomorrow, maybe like 11pm?", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "textMust": ["(11(:00)? ?[Pp]\\.?[Mm]|23:00)"]}, "notes": "A one-off time uses a once schedule. Before once existed, a weekly fixed_time plus a later delete was accepted."} {"id": "control-pr-lookup-question", "area": "pull-requests", "kind": "control", "pattern": "PRs named only as background in a question", "experience": "Asking about PRs must not link, watch, or list them.", "rule": "Do not link unrelated PRs mentioned only as background.", "privacy": "commit-safe", "setup": {"role": "top", "world": {"prs": [{"number": 101, "title": "fix(web): small UI fix", "state": "MERGED", "diff": "diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx\n--- a/apps/web/src/components/Sidebar.tsx\n+++ b/apps/web/src/components/Sidebar.tsx\n@@ -10,1 +10,1 @@\n- {title}\n+ {title}\n"}, {"number": 102, "title": "fix(ws): cap reconnect backoff at 30s", "state": "MERGED", "diff": "diff --git a/apps/web/src/ws/backoff.ts b/apps/web/src/ws/backoff.ts\n--- a/apps/web/src/ws/backoff.ts\n+++ b/apps/web/src/ws/backoff.ts\n@@ -4,1 +4,1 @@\n- return Math.min(base * 2 ** attempt, 60_000);\n+ return Math.min(base * 2 ** attempt, 30_000);\n"}, {"number": 103, "title": "chore: update dependencies", "state": "MERGED", "diff": "diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml\n--- a/pnpm-lock.yaml\n+++ b/pnpm-lock.yaml\n@@ -120,1 +120,1 @@\n- vitest: 4.0.1\n+ vitest: 4.0.3\n"}]}}, "prompt": "Did any of #101, #102, or #103 touch the reconnect backoff logic?", "expect": {"notCalls": ["link_pull_request", "watch_pull_request", "list_thread_pull_requests", "unlink_pull_request"]}, "notes": ""} {"id": "control-link-means-url", "area": "pull-requests", "kind": "control", "pattern": "'link the PR' means show the URL", "experience": "'Link the PR that did this' about a merged culprit PR means give the URL in the reply, not link_pull_request.", "rule": "Do not link unrelated PRs mentioned only as background.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Why isn't the new thread selector grouping projects anymore?"}, {"role": "assistant", "text": "#120, merged yesterday, removed the grouping step when it moved the selector to the new list component.", "toolCalls": [{"tool": "Bash", "input": {"command": "git log --oneline -S groupByProject -- apps/web/src/ThreadSelector.tsx"}, "result": "8e1f2a3 refactor(web): move thread selector to the shared list (#120)"}, {"tool": "Bash", "input": {"command": "git show 8e1f2a3 --stat"}, "result": "commit 8e1f2a3\n\n refactor(web): move thread selector to the shared list (#120)\n\n apps/web/src/ThreadSelector.tsx | 14 ++------------"}]}], "world": {"prs": [{"number": 120, "title": "refactor(web): move thread selector to the shared list", "state": "MERGED", "headRefName": "refactor/selector"}]}}, "prompt": "Link the pr that did this", "expect": {"notCalls": ["link_pull_request", "watch_pull_request"], "textMust": ["github\\.com/acme/app/pull/120"]}, "notes": "Least certain control: 'link' is ambiguous. The user's next message was 'File revert pr', which shows he wanted to see it, not track it."} {"id": "control-nightly-time-question", "area": "pull-requests", "kind": "control", "pattern": "question about a CI schedule is not a schedule ask", "experience": "'When does the next nightly go out?' is answered from the workflow file, with no scheduler tools.", "rule": "none", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time (UTC-7).", "shellMocks": [{"match": "^date\\b", "output": "Sat Oct 24 10:00:00 PDT 2026"}, {"match": "gh run list", "output": "completed\tsuccess\tnightly\tmain\tschedule\t991\t21m\t2026-10-24T09:00:00Z"}], "world": {"files": {".github/workflows/nightly.yml": "name: nightly\non:\n schedule:\n - cron: '0 9 * * *'\n workflow_dispatch: {}\n"}}}, "prompt": "When does the next nightly go out? Want to start testing this asap", "expect": {"notCalls": ["schedule_task", "list_scheduled_tasks", "run_scheduled_task_now"]}, "notes": ""} {"id": "control-fix-with-background-pr", "area": "pull-requests", "kind": "control", "pattern": "ordinary code fix that names a PR", "experience": "A plain code fix that mentions the PR which added the bug needs no PR tools.", "rule": "Do not link unrelated PRs mentioned only as background.", "privacy": "commit-safe", "setup": {"role": "top", "world": {"files": {"src/session.ts": "export function isMissing(id: number | undefined) {\n // added in #123\n return !id;\n}\n"}}}, "prompt": "The isMissing guard that #123 added in src/session.ts treats an id of 0 as missing. Fix it. Don't open a PR, I'll review the diff here.", "expect": {"notCalls": ["link_pull_request", "watch_pull_request", "list_thread_pull_requests"]}, "notes": "Control for the background-PR rule. Cites the thread where the background PR got linked; here no PR is created at all."} {"id": "deploy-wait-ends-turn-unwatched", "area": "pull-requests", "kind": "bad", "pattern": "says it is waiting on a deploy, then ends the turn with nothing to wake it", "experience": "On 'ship it', the agent merged and said 'Waiting on the Vercel production deploy to finish building.', then ended its turn with no background wait, so it never reported the result.", "rule": "Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup.", "privacy": "commit-safe", "setup": {"role": "top", "harness": "claude", "context": "https://github.com/acme/app/pull/123 is linked to this thread and is not watched.", "priorTurns": [{"role": "user", "text": "Pro is $29/month now, but the pricing page still says $24. Fix it."}, {"role": "assistant", "text": "Fixed in [#123](https://github.com/acme/app/pull/123): the Pro plan now costs 2900 cents ($29/month). All checks pass, including the Vercel preview build. Ship it?", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/pricing/plans.ts", "old_string": " pro: { name: \"Pro\", price: 2400, interval: \"month\" },", "new_string": " pro: { name: \"Pro\", price: 2900, interval: \"month\" },"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/pricing/plans.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(web): pricing page shows the current Pro price\" && git push -u origin fix/pro-price"}, "result": "[fix/pro-price abc1234] fix(web): pricing page shows the current Pro price\n 1 file changed, 1 insertion(+), 1 deletion(-)\nbranch 'fix/pro-price' set up to track 'origin/fix/pro-price'."}, {"tool": "Bash", "input": {"command": "gh pr create --fill"}, "result": "https://github.com/acme/app/pull/123"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}, {"tool": "Bash", "input": {"command": "gh pr checks 123"}, "result": "Lint\tpass\t38s\nTypecheck\tpass\t1m4s\nTest\tpass\t2m11s\nVercel\tpass\t6m2s"}]}], "mocks": {"t3_background_wait": "default", "watch_pull_request": "default", "link_pull_request": "default"}, "world": {"branch": "fix/pro-price", "commits": [{"sha": "abc1234", "subject": "fix(web): pricing page shows the current Pro price"}], "diff": "diff --git a/apps/web/src/pricing/plans.ts b/apps/web/src/pricing/plans.ts\n--- a/apps/web/src/pricing/plans.ts\n+++ b/apps/web/src/pricing/plans.ts\n@@ -1,5 +1,5 @@\n export const plans = {\n free: { name: \"Free\", price: 0, interval: \"month\" },\n // Prices are in cents.\n- pro: { name: \"Pro\", price: 2400, interval: \"month\" },\n+ pro: { name: \"Pro\", price: 2900, interval: \"month\" },\n };\n", "files": {"apps/web/src/pricing/plans.ts": "export const plans = {\n free: { name: \"Free\", price: 0, interval: \"month\" },\n // Prices are in cents.\n pro: { name: \"Pro\", price: 2900, interval: \"month\" },\n};\n"}, "prs": [{"number": 123, "title": "fix(web): pricing page shows the current Pro price", "headRefName": "fix/pro-price", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}, {"name": "Vercel", "state": "pass"}]}], "vercel": "The repo is linked to the Vercel project acme/app and the vercel CLI is logged in. Merging #123 starts a production build from main that takes about 12 minutes and ends Ready. Until then `vercel ls` and `vercel inspect` show it as Building. A foreground command that waits for it, such as `vercel inspect --wait`, hits the Bash tool's timeout first and prints only 'Command timed out after 2m 0.0s' (10m 0.0s with the longest timeout). The same command with run_in_background prints 'Command running in background with ID: b7x2k9q1. Output is being written to: /tmp/claude/tasks/b7x2k9q1.output'."}, "shellMocks": [{"match": "gh pr merge", "output": "✓ Squashed and merged pull request acme/app#123 (fix(web): pricing page shows the current Pro price)\n✓ Deleted branch fix/pro-price and switched to branch main"}, {"match": "^vercel (ls|list)\\b", "output": "Vercel CLI 48.2.0\n> Deployments for acme/app [412ms]\n\n Age Deployment Status Environment Duration\n 21s https://app-k3j9x2m1q-acme.vercel.app ● Building Production --\n 2h https://app-p8d7f6e5w-acme.vercel.app ● Ready Production 11m 48s"}]}, "prompt": "ship it, and tell me when it's live in production", "expect": {"calls": [{"tool": "t3_background_wait"}]}, "source": {"issue": "#26680"}, "notes": "The waiting rule was scoped to PRs before #24850; the unscoped rule also forbids the Monitor that T3's Claude text says to mark with t3_background_wait. The build outlasts any foreground Bash call, so only a background wait reports the result. Claude only: t3_background_wait is a Claude tool.", "maxSteps": 16} +{"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."} diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 80f1156663a0b..8298891c5c170 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -14,6 +14,16 @@ const descriptor = { } as const; describe("ExecutionEnvironmentDescriptor", () => { + it("keeps one-time schedules opt-in for older environments", () => { + expect(decodeDescriptor(descriptor).capabilities.oneTimeSchedules).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, oneTimeSchedules: true }, + }).capabilities.oneTimeSchedules, + ).toBe(true); + }); + it("decodes old, recognized and future manual installation descriptors", () => { expect(decodeDescriptor(descriptor).capabilities.serverInstallation).toBeUndefined(); for (const installation of [{ kind: "npx" }, { kind: "npm-global", prefix: "/opt/node" }]) { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4faba348c92a4..d2b77c3e1678d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -174,6 +174,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ linkFence: Schema.optionalKey(Schema.Boolean), /** Server answers server.prepareTriage. */ triage: Schema.optionalKey(Schema.Boolean), + /** Server accepts `{ type: "once" }` scheduled tasks; older servers reject them on save. */ + oneTimeSchedules: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 1474a5d6e87b6..305c1feecac76 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -67,7 +67,7 @@ const OrchestratorMcpSchedule = Schema.Union([ OrchestratorMcpScheduleFromJsonString, ]).annotate({ description: - "Trigger: {type:'interval',everyMs}, {type:'fixed_time',timeOfDay,weekdays?}, or {type:'webhook'}.", + "Trigger: {type:'interval',everyMs}, {type:'fixed_time',timeOfDay,weekdays?}, {type:'once',runAt: ISO timestamp with offset}, or {type:'webhook'}.", }); const OrchestratorMcpTargetOptionsFromRecord = Schema.Record( diff --git a/packages/contracts/src/scheduledTask.test.ts b/packages/contracts/src/scheduledTask.test.ts index 50a49de7f867d..c9b4d597bd0a3 100644 --- a/packages/contracts/src/scheduledTask.test.ts +++ b/packages/contracts/src/scheduledTask.test.ts @@ -22,6 +22,22 @@ describe("ScheduledTaskSchedule", () => { everyMs: MIN_SCHEDULED_TASK_INTERVAL_MS - 1, }); }); + + it("accepts a one-time run only with an offset", () => { + for (const runAt of ["2026-11-03T09:00Z", "2026-11-03T09:00:00.000-08:00"]) { + expect(decodeUpsertSchedule({ type: "once", runAt })).toEqual({ type: "once", runAt }); + } + for (const runAt of [ + "2026-11-03T09:00", + "2026-13-03T09:00Z", + "2026-02-30T09:00Z", + "9999-12-31T23:30-01:00", + "0000-01-01T00:00+00:01", + "tomorrow", + ]) { + expect(() => decodeUpsertSchedule({ type: "once", runAt })).toThrow(); + } + }); }); it.each(["literal", " ", "\t\n", "{{body.bad", "{{unknown.field}}"])( diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index b4dffacd74c04..5fbbc48301d3a 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -45,6 +45,34 @@ const ScheduledTaskFixedTimeSchedule = Schema.Struct({ ), }); +const ISO_WITH_OFFSET = + /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/; + +const YEAR_0000 = Date.parse("0000-01-01T00:00:00Z"); +const YEAR_10000 = Date.parse("+010000-01-01T00:00:00Z"); + +/** Date.parse rolls impossible days like Feb 30 into the next month, so check the day too. */ +function isIsoWithOffset(value: string): boolean { + const match = ISO_WITH_OFFSET.exec(value); + // The server stores UTC, which must stay a four-digit year to decode again. + const time = Date.parse(value); + if (!match || !(time >= YEAR_0000 && time < YEAR_10000)) return false; + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; + return days !== undefined && day >= 1 && day <= days; +} + +// Checked by filter, not pattern, to keep the MCP tool schema small. +const ScheduledTaskOnceSchedule = Schema.Struct({ + type: Schema.Literal("once"), + runAt: TrimmedNonEmptyString.check( + Schema.makeFilter( + (value) => isIsoWithOffset(value) || "Expected an ISO date and time with an offset.", + ), + ), +}); + const ScheduledTaskWebhookSignatureFields = { header: TrimmedNonEmptyString.annotate({ description: "Header with the signature, such as x-hub-signature-256.", @@ -166,6 +194,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ export const ScheduledTaskSchedule = Schema.Union([ ScheduledTaskIntervalSchedule, ScheduledTaskFixedTimeSchedule, + ScheduledTaskOnceSchedule, ScheduledTaskWebhookSchedule, ]); export type ScheduledTaskSchedule = typeof ScheduledTaskSchedule.Type; @@ -180,6 +209,7 @@ export const ScheduledTaskUpsertSchedule = Schema.Union([ }), }), ScheduledTaskFixedTimeSchedule, + ScheduledTaskOnceSchedule, ScheduledTaskUpsertWebhookSchedule, ]); export type ScheduledTaskUpsertSchedule = typeof ScheduledTaskUpsertSchedule.Type; diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 10dc9e4ce579f..4e992d93cabf8 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -9,7 +9,7 @@ Ordinary coding tasks need no T3 tools. - A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. It lists a short set of models per provider; pass \`search\` to find others. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited, and the child finishes in this turn (Claude \`Agent\` always with \`run_in_background:false\`; Codex \`spawn_agent\`, then wait). Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider, background, or explicitly T3-owned child tasks. For a child task that needs a separate checkout, use \`delegate_task\` with \`isolation:"worktree"\` across any provider or model. It starts from the parent local commit and runs project setup; uncommitted edits are not copied. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`, - `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. + `- \`schedule_task\` creates persistent scheduled work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, \`{"type":"once","runAt":"2026-12-01T09:00:00-06:00"}\` for a one-time run, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. - To mention another thread, use the returned environment-scoped link verbatim. For legacy local references, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.`, `### Read shared conversation context diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 5848861f47ac4..64a509e7e1f5f 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -134,10 +134,7 @@ const T3_MCP_TOOLS: Readonly> = { ["Cancel", "Canceling", "Requested cancellation of", "a delegated task"], "task-cancel", ), - schedule_task: tool( - ["Schedule", "Scheduling", "Scheduled", "a recurring task"], - "schedule-create", - ), + schedule_task: tool(["Schedule", "Scheduling", "Scheduled", "a task"], "schedule-create"), list_scheduled_tasks: tool(["List", "Listing", "Listed", "scheduled tasks"], "schedule-list"), update_scheduled_task: tool( ["Update", "Updating", "Updated", "a scheduled task"], From 44dcc69b268150aa4797b8695e0ce5ed5ccabf9c Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 5 Nov 2026 18:57:03 -0600 Subject: [PATCH 13/25] feat(clients): copy thread links that open the app Adapt upstream 251369d31c to use the installed product scheme and environment-scoped references. Keep reached-origin links for external MCP callers and preserve thread chip paste across web and mobile. (cherry picked from commit e442442e1b19d19e2f0688a2f952f8b70dae7308) --- .../T3ComposerEditorModule.kt | 4 + .../t3composereditor/T3ComposerEditorView.kt | 21 +++- .../ios/T3ComposerEditorModule.swift | 5 + .../ios/T3ComposerEditorView.swift | 6 ++ .../HardwareKeyboardCommandProvider.tsx | 2 + .../src/features/threads/ThreadFeed.tsx | 9 +- .../features/threads/thread-list-v2-items.tsx | 2 + .../src/lib/composerContextClipboard.test.ts | 33 +++++++ .../src/lib/composerContextClipboard.ts | 41 +++++++- .../src/lib/enrichedLinkPresentation.test.ts | 8 +- .../src/lib/enrichedLinkPresentation.ts | 2 +- apps/web/src/branding.ts | 5 + apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/CommandPalette.tsx | 2 + apps/web/src/components/Sidebar.tsx | 5 +- .../components/chat/MarkdownThreadLink.tsx | 11 ++- .../composerInlineTokenPaste.test.ts | 59 ++++++++++++ .../components/composerInlineTokenPaste.ts | 65 +++++++++---- apps/web/src/hooks/useThreadActionMenu.ts | 3 +- docs/user/composer.md | 6 +- docs/user/thread-sidebar.md | 8 +- .../src/threadReference.test.ts | 23 ++--- .../client-runtime/src/threadReference.ts | 14 +-- .../src/composerContextReferences.test.ts | 62 ++++++++++++ .../shared/src/composerContextReferences.ts | 41 ++++++++ packages/shared/src/promptLinks.test.ts | 3 + packages/shared/src/threadLinks.test.ts | 22 +++++ packages/shared/src/threadLinks.ts | 96 ++++++++++++++----- 28 files changed, 473 insertions(+), 87 deletions(-) create mode 100644 apps/web/src/components/composerInlineTokenPaste.test.ts diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index 1703a76702024..c9cb2e4fdd42b 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -9,6 +9,10 @@ import org.json.JSONObject import org.json.JSONArray internal object T3ComposerClipboard { + // Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them. + fun hasThreadLink(text: String): Boolean = + text.contains("t3-thread://v2/") || text.contains("://threads/") + fun write(context: Context, text: String, fragment: String) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val payload = try { diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index ecc37df17c993..8181f29082a43 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -697,11 +697,24 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { private fun pasteContextOrImages(): Boolean { val payload = T3ComposerClipboard.read(context) - if (payload["html"]?.contains("data-t3-context-fragment=") == true) { - pasteContextListener?.invoke(payload) - return true + return when { + payload["html"]?.contains("data-t3-context-fragment=") == true -> pasteContext(payload) + pasteImagesOrInterceptedText() -> true + isSingleItemClip() && T3ComposerClipboard.hasThreadLink(payload["text"].orEmpty()) -> + pasteContext(payload) + else -> false } - return pasteImagesOrInterceptedText() + } + + // read() sees only the first item; native paste keeps every item of a longer clip. + private fun isSingleItemClip(): Boolean { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + return clipboard?.primaryClip?.itemCount == 1 + } + + private fun pasteContext(payload: Map): Boolean { + pasteContextListener?.invoke(payload) + return true } private fun pasteImagesOrInterceptedText(): Boolean { diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 99504e6de8936..dc8303aebc8b3 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -4,6 +4,11 @@ import UIKit enum T3ComposerClipboard { static let fragmentType = "app.t3.context-fragment" + // Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them. + static func hasThreadLink(_ text: String) -> Bool { + text.contains("t3-thread://v2/") || text.contains("://threads/") + } + static func write(text: String, fragment: String) { var items: [String: Any] = ["public.utf8-plain-text": text] if let data = fragment.data(using: .utf8), diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index f54f75caf5f50..188ab4a97b316 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -226,6 +226,12 @@ private final class ComposerTextView: UITextView { onPasteText?(text, selectedRange) return } + // read() sees only the first item; native paste keeps every item of a longer pasteboard. + if !bypassTextPasteInterception, pasteboard.numberOfItems == 1, + T3ComposerClipboard.hasThreadLink(context["text", default: ""]) { + onPasteContext?(context) + return + } super.paste(sender) } diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 135715f064924..2c38abc152dfc 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -1,3 +1,4 @@ +import * as Linking from "expo-linking"; import { StackActions, useNavigation } from "@react-navigation/native"; import { Platform } from "react-native"; import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; @@ -62,6 +63,7 @@ export function HardwareKeyboardCommandProvider({ activeThreadRef === null ? null : resolveThreadReferenceCopyTarget({ + appScheme: Linking.resolveScheme({ isSilent: true }), threadRef: activeThreadRef, title: activeThread?.title ?? "", pullRequests: activeThread?.pullRequests, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 0e9b4a9744339..01850498a9909 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,3 +1,4 @@ +import * as Linking from "expo-linking"; import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; @@ -61,7 +62,7 @@ import { } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; -import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; +import { appThreadLink, parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks"; import { parseComposerContextHref, @@ -2910,9 +2911,11 @@ export const ThreadFeed = memo(function ThreadFeed({ else if (actionId === "copy-skill") copyTextWithHaptic(`$${skill.name}`); return; } - if (resolveLinkedThread(href)) { + const thread = resolveLinkedThread(href); + if (thread) { if (actionId === "open-thread") onMarkdownLinkPress(href); - else if (actionId === "copy-thread-link") copyTextWithHaptic(href); + else if (actionId === "copy-thread-link") + copyTextWithHaptic(appThreadLink(thread, Linking.resolveScheme({ isSilent: true }))); return; } const target = resolveFileChipTarget(href, props.workspaceRoot); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 801b309a5f075..fc4ca720161d7 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1,3 +1,4 @@ +import * as Linking from "expo-linking"; import * as Dialog from "../../components/ui/Dialog"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { @@ -957,6 +958,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const target = threadChipCopyTarget( { environmentId: thread.environmentId, threadId: thread.id }, thread.title, + Linking.resolveScheme({ isSilent: true }), ); void copyThreadReference(target).then((didCopy) => { if (!didCopy) Dialog.alert(target.failureTitle, "Try copying this thread again."); diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index afabd7afb550c..8935d4c9592e4 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -36,6 +36,9 @@ vi.mock("../state/session", () => ({ environmentSession: { preparedConnectionValueAtom: vi.fn() }, })); vi.mock("../state/assets", () => ({ assetEnvironment: { createUrl: (value: unknown) => value } })); +vi.mock("../state/threads", () => ({ + environmentThreadShells: { threadShellAtom: (value: unknown) => value }, +})); vi.mock("../state/use-composer-drafts", () => ({ waitForComposerDraftsLoaded: async () => {}, findLocalComposerClipboardAttachment: mocks.local, @@ -330,4 +333,34 @@ describe("mobile context clipboard imports", () => { collectComposerContextReferences(result!.text).map((reference) => reference.contextId), ).toEqual(result!.context.records.map((record) => record.contextId)); }); + + it("pastes a thread link as a chip only where the destination's agent reads its machine", async () => { + const text = [ + "t3code-unlock://threads/destination/local-thread", + "", + ].join(" "); + const result = await importComposerContextClipboard( + { text, fragment: "", html: "" }, + 0, + new AbortController().signal, + 0, + EnvironmentId.make("destination"), + ); + const [record] = result!.context.records; + expect(record).toMatchObject({ + kind: "thread", + environmentId: "destination", + threadId: "local-thread", + }); + expect(result!.text).toBe( + `[Thread](t3-context://v1/thread/${record!.contextId}) `, + ); + expect( + await importComposerContextClipboard( + { text: "plain text", fragment: "", html: "" }, + 0, + new AbortController().signal, + ), + ).toBeNull(); + }); }); diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index e7c7201840569..f4ed214e33724 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -13,13 +13,17 @@ import { decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; -import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; +import { + threadContextsOutsideEnvironment, + threadLinkContextReferences, +} from "@t3tools/shared/composerContextReferences"; import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { environmentSession } from "../state/session"; +import { environmentThreadShells } from "../state/threads"; import { downloadAttachmentForPreview } from "./attachmentDownload"; import { persistComposerAttachmentFile, @@ -70,11 +74,38 @@ export async function importComposerContextClipboard( /** The machines the destination links to, whose threads its agent can read too. */ linkedEnvironments: ReadonlyMap = new Map(), ) { - const fragment = + const copied = decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html); - if (!fragment) return null; - const selected = referencedComposerContext(input.text, { version: 1, records: fragment.records }); - const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4); + // A thread link becomes a chip only where this agent can read its machine; others stay links. + const threads = threadLinkContextReferences( + input.text, + copied?.records ?? [], + (ref) => appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref))?.title, + (record) => + destinationEnvironmentId === undefined || + record.environmentId === destinationEnvironmentId || + linkedEnvironments.has(record.environmentId) + ? record.contextId + : undefined, + ); + const source = + copied?.source ?? + (threads.records[0] ? { environmentId: threads.records[0].environmentId } : null); + if (!source) return null; + const fragment = { + source, + records: [ + ...(copied?.records ?? []).filter((record) => + threads.records.every((thread) => thread.contextId !== record.contextId), + ), + ...threads.records, + ], + }; + const selected = referencedComposerContext(threads.text, { + version: 1, + records: fragment.records, + }); + const imported = reidentifyComposerContext(threads.text, selected?.records ?? [], uuidv4); const skippedThreads = destinationEnvironmentId === undefined ? [] diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 4a3b0174ff67d..2704bf866c211 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -72,7 +72,7 @@ describe("Enriched link presentation", () => { expect( threadChipTargets( here, - "See [A](t3-thread://v1/thread_a), [A again](t3-thread://v1/thread_a), [B](t3-thread://v1/thread_b) and [D](t3-thread://v2/env-there/thread_d).", + "See [A](t3-thread://v1/thread_a), [A again](t3-thread://v1/thread_a), [B](t3-thread://v1/thread_b) [D](t3-thread://v2/env-there/thread_d), and [E](t3code-unlock://threads/env-there/thread%252Fe).", [ { version: 1, @@ -94,6 +94,12 @@ describe("Enriched link presentation", () => { threadId: "thread_d", link: true, }, + { + href: "t3code-unlock://threads/env-there/thread%252Fe", + environmentId: there, + threadId: "thread%2Fe", + link: true, + }, { href: "t3-context://v1/thread/thr1", environmentId: there, diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 12859d2dc8bd8..bb3a4e9d35396 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -58,7 +58,7 @@ export function threadChipTargets( ): ReadonlyArray { const targets = new Map(); if (hasThreadLinks(markdown)) { - for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v[12]\/[^\s)]+)\)/g)) { + for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:[^\s)]+|t3code[^\s)]+)\)/g)) { const link = parseThreadLinkHref(href); if (link) { targets.set(href, { diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 05b4fa6a8f9da..f6b3e1749ffdd 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,3 +1,4 @@ +import { desktopSchemeForVersion } from "@t3tools/shared/forkIdentity"; import type { DesktopAppBranding } from "@t3tools/contracts"; import { formatAppDisplayName } from "./branding.logic"; @@ -25,4 +26,8 @@ export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const APP_LINK_SCHEME = + typeof window !== "undefined" && window.location.protocol.startsWith("t3code") + ? window.location.protocol.slice(0, -1) + : desktopSchemeForVersion(APP_VERSION); export const DESKTOP_DOWNLOAD_URL = "https://t3.codes/download"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 387a7f26d90a4..c2cf8363b7fb1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { APP_LINK_SCHEME } from "~/branding"; import { ChildThreadApprovals } from "./chat/ChildThreadApprovals"; import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; @@ -2329,6 +2330,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef === null || !isServerThread ? null : resolveThreadReferenceCopyTarget({ + appScheme: APP_LINK_SCHEME, threadRef: activeThreadRef, title: activeThread?.title ?? "", openPanelPullRequestUrl, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4e5ef061a8bf8..6a1f70dd40ebc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,3 +1,4 @@ +import { APP_LINK_SCHEME } from "~/branding"; import { HouseIcon } from "lucide-react"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -468,6 +469,7 @@ function OpenCommandPaletteDialog(props: { referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) ? null : resolveThreadReferenceCopyTarget({ + appScheme: APP_LINK_SCHEME, threadRef: referenceThreadRef, title: activeThread?.title ?? "", openPanelPullRequestUrl, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0f4559d3fbaa8..b914086421638 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { APP_LINK_SCHEME } from "~/branding"; import { SidebarHomeNavigation } from "./sidebar/SidebarHomeNavigation"; import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; @@ -2776,7 +2777,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return; case "copy-thread": - void copyThreadReference(threadChipCopyTarget(threadRef, thread.title)); + void copyThreadReference( + threadChipCopyTarget(threadRef, thread.title, APP_LINK_SCHEME), + ); return; case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index 68ab4cd569e0d..f92164154ad57 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,13 +1,15 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId } from "@t3tools/contracts"; import { - formatThreadLink, + appThreadLink, + formatThreadLinkMarkdown, guessThreadLinkRef, percentDecodedThreadLinkId, type ThreadLink, unknownThreadLinkLabel, } from "@t3tools/shared/threadLinks"; +import { APP_LINK_SCHEME } from "~/branding"; import { useThreadRefs, useThreadShell } from "../../state/entities"; import { UnresolvedChip } from "../contextChipParts"; import { ThreadContextChip } from "../ThreadContextChip"; @@ -19,7 +21,7 @@ export function MarkdownThreadLink(props: { readonly label: string | null; }) { const environmentId = props.link.environmentId ?? props.environmentId; - const decodedId = percentDecodedThreadLinkId(props.link.threadId); + const decodedId = props.link.exact ? null : percentDecodedThreadLinkId(props.link.threadId); const written = useThreadShell(scopeThreadRef(environmentId, props.link.threadId)); const decoded = useThreadShell( written === null && decodedId !== null ? scopeThreadRef(environmentId, decodedId) : null, @@ -36,6 +38,9 @@ export function MarkdownThreadLink(props: { const ref = { environmentId: thread.environmentId, threadId: thread.id }; const title = thread.title.trim() || props.label || "Untitled thread"; return ( - + ); } diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts new file mode 100644 index 0000000000000..f89142f882d94 --- /dev/null +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -0,0 +1,59 @@ +import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; +import { type ComposerContextClipboardFragment, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + encodeComposerContextFragment, +} from "@t3tools/shared/composerContextClipboard"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { describe, expect, it } from "vite-plus/test"; + +import { importPastedComposerText } from "./composerInlineTokenPaste"; + +const thread = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; +const clipboard = (data: Record) => ({ + getData: (type: string) => data[type] ?? "", +}); +const chip = (title: string) => + `[${title}](t3-context://v1/thread/${threadContextRecord(thread, title).contextId})`; + +/** Imports every record it is offered, as the composer does for a thread its agent can read. */ +const importAll = + (imported: Array) => + (fragment: ComposerContextClipboardFragment) => { + imported.push(fragment); + return new Map(fragment.records.map((record) => [record.contextId, record.contextId])); + }; + +describe("importPastedComposerText", () => { + it("pastes a copied thread link back as the chip it was copied from", () => { + const target = threadChipCopyTarget(thread, "Fix the login flow"); + const imported: Array = []; + const text = importPastedComposerText( + clipboard({ + "text/plain": `See ${target.value}`, + [COMPOSER_CONTEXT_CLIPBOARD_MIME]: encodeComposerContextFragment(target.fragment!)!, + }), + importAll(imported), + ); + expect(text).toBe(`See ${chip("Fix the login flow")}`); + expect(imported.map((fragment) => fragment.records)).toEqual([target.fragment!.records]); + }); + + it("makes a chip of a v2 link pasted without T3's clipboard format", () => { + const text = importPastedComposerText( + clipboard({ "text/plain": "[Login](t3-thread://v2/env-1/thread-1)" }), + importAll([]), + ); + expect(text).toBe(chip("Login")); + }); + + it("keeps the link when the composer cannot take the thread", () => { + const pasted = "t3code-unlock://threads/env-1/thread-1"; + expect(importPastedComposerText(clipboard({ "text/plain": pasted }), () => new Map())).toBe( + pasted, + ); + }); +}); diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 32722ad6a9571..0862f81ee8355 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -1,5 +1,9 @@ import { ComposerContextId } from "@t3tools/contracts"; -import type { ComposerContextClipboardFragment } from "@t3tools/contracts"; +import type { + ComposerContextClipboardFragment, + ComposerContextRecord, + ScopedThreadRef, +} from "@t3tools/contracts"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, decodeComposerContextFragment, @@ -9,7 +13,14 @@ import { collectComposerContextReferences, formatComposerContextReference, replaceComposerContextReferences, + threadLinkContextReferences, } from "@t3tools/shared/composerContextReferences"; + +import { readThreadShell } from "~/state/entities"; + +const threadTitle = (ref: ScopedThreadRef) => readThreadShell(ref)?.title; + +/** Pasted thread links count as context too, so they become chips like a copied chip does. */ export function readPastedComposerContext( clipboardData: Pick, ): ComposerContextClipboardFragment | null { @@ -17,11 +28,20 @@ export function readPastedComposerContext( const decodedFragment = decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); - if (decodedFragment === null) return null; + const threads = threadLinkContextReferences( + pastedText, + decodedFragment?.records ?? [], + threadTitle, + ); + const source = + decodedFragment?.source ?? + (threads.records[0] ? { environmentId: threads.records[0].environmentId } : null); + if (source === null) return null; const pastedIds = new Set( - collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), + collectComposerContextReferences(threads.text).map((occurrence) => occurrence.contextId), ); - for (const record of decodedFragment.records) { + const records = new Map(); + for (const record of [...(decodedFragment?.records ?? []), ...threads.records]) { if ( record.kind === "preview-annotation" && !("payload" in record) && @@ -30,10 +50,12 @@ export function readPastedComposerContext( ) { pastedIds.add(record.screenshotContextId); } + records.set(record.contextId, record); } return { - ...decodedFragment, - records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), + version: 1, + source, + records: [...records.values()].filter((record) => pastedIds.has(record.contextId)), }; } @@ -47,18 +69,23 @@ export function importPastedComposerText( const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; - const text = - rewrittenIds && rewrittenIds.size > 0 - ? replaceComposerContextReferences(pastedText, (occurrence) => { - const nextId = rewrittenIds.get(occurrence.contextId); - return nextId - ? formatComposerContextReference({ - ...occurrence, - contextId: ComposerContextId.make(nextId), - kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind, - }) - : occurrence.source; + if (!rewrittenIds || rewrittenIds.size === 0) return pastedText; + const nextId = (contextId: string) => { + const id = rewrittenIds.get(contextId); + return id ? ComposerContextId.make(id) : undefined; + }; + const text = replaceComposerContextReferences(pastedText, (occurrence) => { + const contextId = nextId(occurrence.contextId); + return contextId + ? formatComposerContextReference({ + ...occurrence, + contextId, + kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind, }) - : pastedText; - return text; + : occurrence.source; + }); + // A thread the import refused, on a machine this agent cannot read, stays the link it was. + return threadLinkContextReferences(text, fragment?.records ?? [], threadTitle, (record) => + nextId(record.contextId), + ).text; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 6a1a7f7312d86..241e2dae17653 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -1,3 +1,4 @@ +import { APP_LINK_SCHEME } from "~/branding"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; import { openGuestShareDialog } from "../components/chat/guestShareDialogState"; @@ -297,7 +298,7 @@ export function useThreadActionMenu(input?: { } return; case "copy-thread": - void copyThreadReference(threadChipCopyTarget(threadRef, thread.title)); + void copyThreadReference(threadChipCopyTarget(threadRef, thread.title, APP_LINK_SCHEME)); return; case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); diff --git a/docs/user/composer.md b/docs/user/composer.md index db41f6cc013fe..0a602958f286d 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -285,9 +285,9 @@ Another thread can be context too. Type `@` followed by part of its title to pic the same server, or from a machine it links to while the Linked environments beta is on; those show the machine's name. On web and desktop you can also drag a thread out of the sidebar and drop it on the composer; a multi-selection drops together. On any client, **Copy thread** puts a -thread on the clipboard that pastes into a composer as its chip. The chip shows the thread's -current title and opens it when selected. Your prompt only carries a reference: the agent reads the thread's -history on demand, so attaching a long thread costs nothing until the agent looks. Attaching a +link on the clipboard that pastes into a composer as a chip when its agent can read the thread. +The chip shows the thread's current title and opens it when selected. The prompt carries a +reference, and the agent reads the thread's history on demand. Attaching a thread does not change it, and the agent cannot send messages to it unless you ask. A thread from a linked machine, picked, pasted or dropped, stays a reference that the agent reads diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ad3046762124d..bb0cb494b3f77 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -302,9 +302,11 @@ palette. Use **Settings → Keybindings** to find or customize shortcuts for searching files and copying a thread reference. A copied reference uses the thread's pull request -link when available, otherwise the thread itself, which pastes into a composer as -a thread chip. To copy the thread even when it has a PR, choose **Copy → Thread** -from its context menu, or **Copy thread** from its long-press menu on mobile. +link when available. A copied thread link opens in the installed app with a +connection to the thread's environment and pastes into a composer as a chip when +its agent can read that thread. To copy the thread even when it has a PR, choose +**Copy → Thread** from its context menu, or **Copy thread** from its long-press +menu on mobile. **Thread ID** copies the bare ID for scripts and the `t3` CLI. See [keybindings](./keybindings.md) for custom configuration. diff --git a/packages/client-runtime/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts index 9fa3245df5754..05cb0cda196c3 100644 --- a/packages/client-runtime/src/threadReference.test.ts +++ b/packages/client-runtime/src/threadReference.test.ts @@ -3,7 +3,7 @@ import { decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { threadLinkContextReferences } from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; import { resolveThreadReferenceCopyTarget } from "./threadReference.ts"; @@ -77,11 +77,16 @@ describe("resolveThreadReferenceCopyTarget", () => { ).toMatchObject({ kind: "pull-request", value: "https://github.com/t3/pr/12" }); }); - it("copies a thread chip that pastes back as the same thread on its machine", () => { - const target = resolveThreadReferenceCopyTarget({ ...thread, linkedPullRequestUrl: null }); + it("copies a fork app link that pastes back as the same scoped thread", () => { + const target = resolveThreadReferenceCopyTarget({ + ...thread, + linkedPullRequestUrl: null, + appScheme: "t3code-unlock", + }); expect(target).toMatchObject({ kind: "thread", + value: "t3code-unlock://threads/env-1/thread-1", description: "Fix the login flow", successTitle: "Thread copied", }); @@ -98,13 +103,9 @@ describe("resolveThreadReferenceCopyTarget", () => { }), ], }); - // A paste keeps only the records its plain text references. - expect(collectComposerContextReferences(target!.value)).toEqual([ - expect.objectContaining({ - kind: "thread", - contextId: pasted!.records[0]!.contextId, - label: "Fix the login flow", - }), - ]); + expect(threadLinkContextReferences(target!.value, pasted!.records, () => undefined)).toEqual({ + text: `[Fix the login flow](t3-context://v1/thread/${pasted!.records[0]!.contextId})`, + records: pasted!.records, + }); }); }); diff --git a/packages/client-runtime/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts index 94f68202a62cf..9d991249a6a0c 100644 --- a/packages/client-runtime/src/threadReference.ts +++ b/packages/client-runtime/src/threadReference.ts @@ -3,16 +3,14 @@ import type { ScopedThreadRef, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { - formatComposerContextReference, - threadContextRecord, -} from "@t3tools/shared/composerContextReferences"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { appThreadLink } from "@t3tools/shared/threadLinks"; import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts"; export interface ThreadReferenceCopyTarget { readonly kind: "pull-request" | "thread"; - /** The plain text: a PR URL, or a thread chip's reference link. */ + /** The plain text: a PR URL, or an app link. */ readonly value: string; /** What a toast or palette row shows for the copy. */ readonly description: string; @@ -27,11 +25,12 @@ export interface ThreadReferenceCopyTarget { export function threadChipCopyTarget( threadRef: ScopedThreadRef, title: string, + scheme?: string, ): ThreadReferenceCopyTarget { const record = threadContextRecord(threadRef, title); return { kind: "thread", - value: formatComposerContextReference(record), + value: appThreadLink(threadRef, scheme), description: record.title, fragment: { version: 1, source: { environmentId: threadRef.environmentId }, records: [record] }, clipboardTarget: "thread", @@ -47,6 +46,7 @@ export function resolveThreadReferenceCopyTarget(input: { readonly openPanelPullRequestUrl?: string | null | undefined; readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequestUrl?: string | null; + readonly appScheme?: string; }): ThreadReferenceCopyTarget | null { if (input.openPanelPullRequestUrl === null) return null; const pullRequestUrl = @@ -62,5 +62,5 @@ export function resolveThreadReferenceCopyTarget(input: { successTitle: "PR link copied", failureTitle: "Failed to copy PR link", } - : threadChipCopyTarget(input.threadRef, input.title); + : threadChipCopyTarget(input.threadRef, input.title, input.appScheme); } diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index a90312bc5c432..2539a78f7fa5b 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -17,6 +17,7 @@ import { stripAttachmentOnlyBootstrapPrompt, threadContextRecord, threadContextsOutsideEnvironment, + threadLinkContextReferences, toComposerContextId, toKindScopedComposerContextId, } from "./composerContextReferences.ts"; @@ -441,3 +442,64 @@ describe("threadContextRecord", () => { ).toBe("thread_abc-123"); }); }); + +describe("threadLinkContextReferences", () => { + const thread = (environmentId: string, threadId: string) => ({ + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }); + const reference = (environmentId: string, threadId: string, label: string) => + `[${label}](t3-context://v1/thread/${threadContextRecord(thread(environmentId, threadId), label).contextId})`; + + it("makes a chip of a copied link, a v2 link, and a labeled link, titled here", () => { + const pasted = threadLinkContextReferences( + [ + "t3code-unlock://threads/env-1/t1", + "", + "[Written](t3code-unlock://threads/env-2/t3)", + ].join(" "), + [], + ({ threadId }) => (threadId === "t3" ? undefined : `Title ${threadId}`), + ); + expect(pasted.text).toBe( + [ + reference("env-1", "t1", "Title t1"), + reference("env-1", "t2", "Title t2"), + reference("env-2", "t3", "Written"), + ].join(" "), + ); + expect(pasted.records).toEqual([ + threadContextRecord(thread("env-1", "t1"), "Title t1"), + threadContextRecord(thread("env-1", "t2"), "Title t2"), + threadContextRecord(thread("env-2", "t3"), "Written"), + ]); + }); + + it("prefers the record the copy carried, and names an unknown thread plainly", () => { + const copied = threadContextRecord(thread("env-1", "t1"), "Copied title"); + const pasted = threadLinkContextReferences( + "t3code-unlock://threads/env-1/t1 t3code-unlock://threads/env-9/t9", + [copied], + () => "Here", + ); + expect(pasted.records).toEqual([copied, threadContextRecord(thread("env-9", "t9"), "Here")]); + expect( + threadLinkContextReferences("t3code-unlock://threads/env-9/t9", [], () => undefined).records, + ).toEqual([threadContextRecord(thread("env-9", "t9"), "Thread")]); + }); + + it("keeps links it may not chip, v1 links, and links in code as written", () => { + const text = [ + "t3code-unlock://threads/elsewhere/t1", + "[Old](t3-thread://v1/t2)", + "`t3code-unlock://threads/env-1/t3`", + ].join(" "); + const pasted = threadLinkContextReferences( + text, + [], + () => "Title", + () => undefined, + ); + expect(pasted).toEqual({ text, records: [] }); + }); +}); diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 85ba03e973b2c..7f3e10e84a3fe 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -10,6 +10,8 @@ import { type ThreadContextRecord, } from "@t3tools/contracts"; +import { replaceThreadLinks, threadLinkCandidates } from "./threadLinks.ts"; + const CONTEXT_PROTOCOL = "t3-context:"; const COMPOSER_CONTEXT_HREF_PREFIX = `${CONTEXT_PROTOCOL}//v1/`; const CONTEXT_KIND_PATTERN = /^[a-z][a-z0-9-]{0,39}$/; @@ -89,6 +91,45 @@ export function threadContextRecord(ref: ScopedThreadRef, title: string): Thread }; } +/** + * Turns pasted thread links into thread chip references. A record the copy carried names its + * chip, else the thread's title here, read by key, or the link's own label. `accept` gives the + * chip's id, or none to keep the link as written. A v1 link names no machine, so it stays a link. + */ +export function threadLinkContextReferences( + text: string, + copied: ReadonlyArray, + title: (ref: ScopedThreadRef) => string | undefined, + accept: (record: ThreadContextRecord) => ComposerContextId | undefined = (record) => + record.contextId, +): { readonly text: string; readonly records: ReadonlyArray } { + const records = new Map(); + const rewritten = replaceThreadLinks(text, (link, label) => { + if (link.environmentId === null) return undefined; + const candidates = threadLinkCandidates(link, link.environmentId); + const record = + copied.find( + (candidate): candidate is ThreadContextRecord => + candidate.kind === "thread" && + "threadId" in candidate && + candidates.some( + (ref) => + ref.environmentId === candidate.environmentId && ref.threadId === candidate.threadId, + ), + ) ?? + candidates.flatMap((ref) => { + const known = title(ref); + return known === undefined ? [] : [threadContextRecord(ref, known)]; + })[0] ?? + threadContextRecord(candidates[0]!, label?.trim() || "Thread"); + const contextId = accept(record); + if (contextId === undefined) return undefined; + records.set(record.contextId, record); + return formatComposerContextReference({ ...record, contextId }); + }); + return { text: rewritten, records: [...records.values()] }; +} + export function formatComposerContextReference(reference: { kind: ComposerContextKind; contextId: ComposerContextId; diff --git a/packages/shared/src/promptLinks.test.ts b/packages/shared/src/promptLinks.test.ts index afc5644f9ac3d..64f6888738a93 100644 --- a/packages/shared/src/promptLinks.test.ts +++ b/packages/shared/src/promptLinks.test.ts @@ -108,6 +108,9 @@ describe("link round trips", () => { it("maps app scheme and hosted links to in-app paths, and nothing else", () => { expect(promptLinkAppPath("t3code://new?prompt=hi")).toBe("/new?prompt=hi"); expect(promptLinkAppPath("t3code-dev://threads/env-1/thread-1")).toBe("/env-1/thread-1"); + expect(promptLinkAppPath("t3code-unlock://threads/remote/folder%252Fthread")).toBe( + "/remote/folder%252Fthread", + ); expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1?prompt=go")).toBe( "/env-1/thread-1?prompt=go", ); diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index bda540727c1d9..e6d0f0c155a90 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; import { + appThreadLink, formatThreadLink, guessThreadLinkRef, parseThreadLinkHref, @@ -181,3 +182,24 @@ describe("thread links", () => { expect(formatThreadLink(ref("e", "t1"), " ] ")).toBe(""); }); }); + +it("keeps copied app links exact and scoped without a network origin", () => { + const ref = { + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("folder%2Fthread"), + }; + const href = appThreadLink(ref, "t3code-unlock"); + expect(href).toBe("t3code-unlock://threads/remote/folder%252Fthread"); + expect(parseThreadReference(href)).toEqual({ ...ref, exact: true }); + expect(relabelThreadLinks(href, ref.environmentId, () => "Current title")).toBe( + `[Current title](${href})`, + ); + expect( + threadLinkRefs( + `Open ${href}. +\`${href.replace("remote", "hidden")}\``, + ref.environmentId, + ), + ).toEqual([ref]); + expect(parseThreadLinkHref("https://app.t3.codes/threads/remote/folder%252Fthread")).toBeNull(); +}); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 66686e0d4922c..64f7ed3ce18cd 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -5,9 +5,17 @@ import * as Schema from "effect/Schema"; export const THREAD_LINK_PROTOCOL = "t3-thread"; const V1_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`; -// Code comes first in the alternation so a link written inside a code span or fence is skipped. -const THREAD_LINK_OUTSIDE_CODE = - /(?(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(`+)[^\n]*?\4)|\[(?