diff --git a/.agents/skills/capture-ui-evidence/SKILL.md b/.agents/skills/capture-ui-evidence/SKILL.md new file mode 100644 index 000000000..09cab32b8 --- /dev/null +++ b/.agents/skills/capture-ui-evidence/SKILL.md @@ -0,0 +1,35 @@ +--- +name: capture-ui-evidence +description: Use the standard Jones Code UI verification and evidence workflow for UI changes and screenshots. Route interactive shared-renderer checks to T3 Browser, native Linux desktop scenarios to the Electron harness after exact-revision qualification, and mobile checks to test-t3-mobile. +--- + +# Capture Jones Code UI evidence + +This is the default workflow for verifying user-visible UI changes and recording evidence. Choose the affected client: + +- Shared web/desktop renderer: [test-t3-app](../test-t3-app/SKILL.md) and T3's Browser panel for live inspection, clicks, typing, screenshots and iteration. +- Linux native desktop shell: the Electron runner below for repeatable screenshots and scripted interactions after exact-revision qualification. +- Native mobile: [test-t3-mobile](../test-t3-mobile/SKILL.md). + +Exercise the changed flow, assert its observable result, and check backend readback or reload persistence when the change depends on them. Use the same evidence format on every route: full candidate/comparison revisions (plus patch identity for dirty source), build correspondence, client/route, actual viewport/scale/theme, meaningful fixture, action and readback, captures, and coverage limits. The source/build distinction in [runner output and evidence limits](references/runner-output-and-limits.md) applies to Browser evidence too; a dev source OID alone is not an attestation of the served bundle. + +T3 Browser remains the shared-renderer route. An unavailable Preview does not authorize switching to a standalone browser or using Electron as a Browser fallback. + +The Linux Electron runner is a separate route for behavior that depends on the desktop shell. Require a recorded passing qualification for the exact harness revision; exploratory output alone leaves Electron-specific behavior unverified. This nightly source port does not qualify a host or adopt an installed wrapper. A separately installed host command must remain pinned to its qualified revision, with readback in `~/.local/state/jones-code-ui-evidence/qualification.json`; an older source qualification does not qualify this port. + +The runner launches existing built outputs; it does not build the app. On a host with the installed command, first run `jones-code-ui-evidence doctor`, then use `jones-code-ui-evidence run --source /absolute/candidate --build-receipt /absolute/candidate-receipt.json` with a scenario that exercises the changed flow. The default `sidebar-rename` is a qualification/smoke scenario; it does not verify an unrelated UI change. A custom scenario receives a Playwright page and Electron app for inspecting and interacting with the candidate inside the isolated run. + +From the Jones Code repository root, use: + +```sh +node apps/desktop/scripts/ui-evidence.mjs doctor [--source DIR] +node apps/desktop/scripts/ui-evidence.mjs setup [--source DIR] +node apps/desktop/scripts/ui-evidence.mjs run [--source DIR] [--scenario sidebar-rename|/absolute/path/scenario.mjs] [--size 1280x800] [--scale 1] [--theme system|light|dark] [--comparison OID] [--build-receipt FILE] [--out DIR] +node apps/desktop/scripts/ui-evidence.mjs cleanup --run +``` + +The repository also exposes the CLI and its bounded test group as package scripts `ui:evidence` and `test:ui-evidence`, invoked through the existing `vp run ", + transcript_provider: "local", + language: "en", + edited: false, + created_at: "2026-10-02T12:00:00Z", + updated_at: "2026-10-02T12:00:00Z", + due_at: "2026-10-02T12:03:00Z", + remaining_ms: 180000, + expires_at: "2026-10-03T12:00:00Z", + command_id: null, + command_status: null, + reason: null, + server_now: "2026-10-02T12:00:00Z", + ...overrides, + }); +} +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("voice review acknowledged actions", () => { + it("does not enable editing before the acknowledgement; save and send uses the saved revision and literal text", async () => { + const begin = deferred(); + const mutate = vi + .fn() + .mockImplementationOnce(() => begin.promise) + .mockResolvedValueOnce({ + draft: draft({ revision: 3, state: "paused", text: "edited", edited: true }), + edit_handle: null, + }) + .mockResolvedValueOnce({ + draft: draft({ revision: 4, state: "released", text: null }), + edit_handle: null, + }); + const actions = new VoiceReviewActions(draft(), { mutate, get: vi.fn() }); + const pending = actions.act("edit-begin"); + expect(actions.editHandle).toBeNull(); + expect(actions.busy).toBe(true); + begin.resolve({ draft: draft({ revision: 2, state: "editing" }), edit_handle: "opaque" }); + await pending; + actions.setText("edited"); + await actions.act("send-now"); + expect(mutate).toHaveBeenCalledTimes(1); + await actions.act("edit-save", true); + expect(mutate.mock.calls[1]).toEqual([ + "capture-1", + "edit-save", + { expected_revision: 2, text: "edited", edit_handle: "opaque" }, + ]); + expect(mutate.mock.calls[2]).toEqual(["capture-1", "send-now", { expected_revision: 3 }]); + expect(actions.draft.state).toBe("released"); + expect(actions.editHandle).toBeNull(); + }); + + it("preserves unsaved text on conflict and never sends when save was not acknowledged", async () => { + const mutate = vi + .fn() + .mockResolvedValueOnce({ + draft: draft({ revision: 2, state: "editing" }), + edit_handle: "opaque", + }) + .mockRejectedValueOnce({ _tag: "VoiceReviewConflictError" }); + const get = vi + .fn() + .mockResolvedValue(draft({ revision: 3, state: "editing", text: "server text" })); + const actions = new VoiceReviewActions(draft(), { mutate, get }); + await actions.act("edit-begin"); + actions.setText("unsaved local text"); + await actions.act("edit-save", true); + actions.receive(draft({ revision: 3, state: "editing", text: "server text" })); + expect(actions.text).toBe("unsaved local text"); + expect(actions.draft.revision).toBe(3); + expect(mutate).toHaveBeenCalledTimes(2); + expect(get).toHaveBeenCalledWith("capture-1"); + }); + + it("observes ambiguous mutations once and gates new mutations until explicit reconciliation", async () => { + const mutate = vi.fn().mockRejectedValue(new Error("private upstream exception")); + const get = vi.fn().mockResolvedValue(draft({ state: "released", revision: 2, text: null })); + const actions = new VoiceReviewActions(draft(), { mutate, get }); + await actions.act("send-now"); + expect(actions.uncertain).toBe(true); + expect(actions.error).not.toContain("private"); + await actions.act("send-now"); + expect(mutate).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledTimes(1); + await actions.reconcile(); + expect(actions.uncertain).toBe(false); + }); + + it("keeps another prompt actionable while the first response is delayed", async () => { + const slow = deferred(); + const first = new VoiceReviewActions(draft(), { mutate: () => slow.promise, get: vi.fn() }); + const secondMutation = vi.fn().mockResolvedValue({ + draft: draft({ id: "capture-2", revision: 2, state: "deleted", text: null }), + edit_handle: null, + }); + const second = new VoiceReviewActions(draft({ id: "capture-2" }), { + mutate: secondMutation, + get: vi.fn(), + }); + const pending = first.act("pause"); + await second.act("delete"); + expect(second.draft.state).toBe("deleted"); + expect(first.busy).toBe(true); + slow.resolve({ draft: draft({ revision: 2, state: "paused" }), edit_handle: null }); + await pending; + }); + + it("save and cancel finish paused; a fresh component does not restore an edit handle or play", async () => { + for (const action of ["edit-save", "edit-cancel"] as const) { + const mutate = vi + .fn() + .mockResolvedValueOnce({ + draft: draft({ revision: 2, state: "editing" }), + edit_handle: "holder", + }) + .mockResolvedValueOnce({ + draft: draft({ revision: 3, state: "paused" }), + edit_handle: null, + }); + const actions = new VoiceReviewActions(draft(), { mutate, get: vi.fn() }); + await actions.act("edit-begin"); + await actions.act(action); + expect(actions.draft.state).toBe("paused"); + expect(actions.editHandle).toBeNull(); + } + const mutate = vi.fn(); + const remount = new VoiceReviewActions(draft({ state: "editing", revision: 2 }), { + mutate, + get: vi.fn(), + }); + expect(remount.editHandle).toBeNull(); + await remount.act("play"); + expect(mutate).not.toHaveBeenCalled(); + }); + + it("projects held countdowns without a mutation and freezes paused/editing projections", () => { + expect(remainingSeconds(draft(), 1500)).toBe(179); + expect(remainingSeconds(draft(), 300000)).toBe(0); + expect(remainingSeconds(draft({ state: "paused" }), 300000)).toBe(180); + expect(remainingSeconds(draft({ state: "editing" }), 300000)).toBe(180); + expect(voiceReviewError({ _tag: "VoiceReviewNotConfiguredError" })).toContain("not configured"); + expect(voiceReviewError({ _tag: "VoiceReviewNotFoundError" })).toContain("unavailable"); + }); +}); diff --git a/apps/web/src/components/voiceReview/voiceReviewActions.ts b/apps/web/src/components/voiceReview/voiceReviewActions.ts new file mode 100644 index 000000000..afbc5e553 --- /dev/null +++ b/apps/web/src/components/voiceReview/voiceReviewActions.ts @@ -0,0 +1,137 @@ +import type { + VoiceReviewAction, + VoiceReviewDraft, + VoiceReviewMutationPayload, + VoiceReviewMutationResult, +} from "@t3tools/contracts"; + +export interface VoiceReviewTransport { + mutate( + id: string, + action: VoiceReviewAction, + payload: VoiceReviewMutationPayload, + ): Promise; + get(id: string): Promise; +} + +export function voiceReviewError(error: unknown): string { + const tag = typeof error === "object" && error !== null && "_tag" in error ? error._tag : null; + switch (tag) { + case "VoiceReviewNotConfiguredError": + return "Voice review is not configured for this environment."; + case "VoiceReviewForbiddenError": + return "This session cannot review voice prompts."; + case "VoiceReviewNotFoundError": + return "Voice review is unavailable on this environment, or this prompt no longer exists."; + case "VoiceReviewConflictError": + return "This prompt changed. Your unsaved text is preserved; review its current state before acting again."; + default: + return "The result is uncertain. Check the current prompt state before taking another action."; + } +} + +export class VoiceReviewActions { + draft: VoiceReviewDraft; + text: string; + editHandle: string | null = null; + busy = false; + error: string | null = null; + uncertain = false; + observedAt = performance.now(); + private listeners = new Set<() => void>(); + private version = 0; + constructor( + draft: VoiceReviewDraft, + private transport: VoiceReviewTransport, + ) { + this.draft = draft; + this.observedAt = performance.now(); + this.text = draft.text ?? ""; + } + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + snapshot = () => this.version; + private notify() { + this.version++; + this.listeners.forEach((listener) => listener()); + } + receive(draft: VoiceReviewDraft) { + if (draft.revision < this.draft.revision) return; + this.draft = draft; + this.observedAt = performance.now(); + if (this.editHandle === null) this.text = draft.text ?? ""; + this.notify(); + } + setText(text: string) { + this.text = text; + this.notify(); + } + async act(action: VoiceReviewAction, sendAfterSave = false) { + if (this.busy || this.uncertain) return; + if (["released", "deleted", "expired"].includes(this.draft.state)) return; + if (this.draft.state === "editing" && (action === "send-now" || action === "play")) return; + if ((action === "edit-save" || action === "edit-cancel") && this.editHandle === null) return; + this.busy = true; + this.error = null; + this.notify(); + try { + const payload: VoiceReviewMutationPayload = { + expected_revision: this.draft.revision, + ...(action === "edit-save" ? { text: this.text, edit_handle: this.editHandle! } : {}), + ...(action === "edit-cancel" ? { edit_handle: this.editHandle! } : {}), + }; + const result = await this.transport.mutate(this.draft.id, action, payload); + this.draft = result.draft; + this.observedAt = performance.now(); + this.editHandle = result.edit_handle; + if (action === "edit-save" || action === "edit-cancel") this.text = result.draft.text ?? ""; + if (sendAfterSave && action === "edit-save") { + const sent = await this.transport.mutate(this.draft.id, "send-now", { + expected_revision: result.draft.revision, + }); + this.draft = sent.draft; + this.observedAt = performance.now(); + } + } catch (error) { + this.error = voiceReviewError(error); + const tag = + typeof error === "object" && error !== null && "_tag" in error ? error._tag : null; + this.uncertain = + tag !== "VoiceReviewConflictError" && + tag !== "VoiceReviewForbiddenError" && + tag !== "VoiceReviewNotConfiguredError" && + tag !== "VoiceReviewNotFoundError"; + // A possibly effective mutation is observed by the same ID, never retried. + try { + this.draft = await this.transport.get(this.draft.id); + this.observedAt = performance.now(); + } catch { + /* Keep the last acknowledged state until observation succeeds. */ + } + } finally { + this.busy = false; + this.notify(); + } + } + async reconcile() { + try { + this.receive(await this.transport.get(this.draft.id)); + this.uncertain = false; + this.error = null; + } catch (error) { + this.error = voiceReviewError(error); + } + this.notify(); + } +} + +export function remainingSeconds(draft: VoiceReviewDraft, elapsedMs: number) { + if (draft.remaining_ms === null) return null; + return Math.ceil( + Math.max(0, draft.remaining_ms - (draft.state === "held" ? elapsedMs : 0)) / 1000, + ); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index df7969a86..647da10fa 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -19,6 +19,7 @@ import { type ProviderOptionSelection, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { collectAssistantCitations, serializeAssistantCitation, @@ -70,6 +71,7 @@ import { composerDraftHasUserContent, beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, + deriveEffectiveComposerModelState, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, restoreFailedBackgroundDraftThread, @@ -1932,6 +1934,53 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("inherits reasoning effort after switching models while preserving speed options", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection( + threadRef, + modelSelection(CODEX_DRIVER, "old-model", { + reasoningEffort: "low", + fastMode: true, + serviceTier: "priority", + }), + ); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "new-model")); + + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], + ).toEqual( + modelSelection(CODEX_DRIVER, "new-model", { fastMode: true, serviceTier: "priority" }), + ); + }); + + it("keeps same-model reasoning effort when a model-only selection is repeated", () => { + const store = useComposerDraftStore.getState(); + const selection = modelSelection(CODEX_DRIVER, "current-model", { reasoningEffort: "low" }); + store.setModelSelection(threadRef, selection); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "current-model")); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], + ).toEqual(selection); + }); + + it("does not restore old thread effort into an optionless draft selection", () => { + const state = deriveEffectiveComposerModelState({ + draft: { + activeProvider: CODEX_INSTANCE, + modelSelectionByProvider: { + [CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "new-model"), + }, + }, + providers: [], + selectedProvider: CODEX_DRIVER, + selectedInstanceId: CODEX_INSTANCE, + threadModelSelection: modelSelection(CODEX_DRIVER, "old-model", { reasoningEffort: "low" }), + projectModelSelection: modelSelection(CODEX_DRIVER, "new-model", { reasoningEffort: "high" }), + settings: DEFAULT_UNIFIED_SETTINGS, + }); + expect(state.modelOptions).toBeNull(); + }); + it("keeps default-only model selections on the draft", () => { const store = useComposerDraftStore.getState(); store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); @@ -2515,6 +2564,21 @@ describe("composerDraftStore sticky composer settings", () => { ); }); + it("inherits sticky reasoning effort after switching models while preserving speed options", () => { + const store = useComposerDraftStore.getState(); + store.setStickyModelSelection( + modelSelection(CODEX_DRIVER, "old-model", { + reasoningEffort: "low", + fastMode: false, + serviceTier: "priority", + }), + ); + store.setStickyModelSelection(modelSelection(CODEX_DRIVER, "new-model")); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual( + modelSelection(CODEX_DRIVER, "new-model", { fastMode: false, serviceTier: "priority" }), + ); + }); + it("applies sticky activeProvider to new drafts", () => { const store = useComposerDraftStore.getState(); const threadId = ThreadId.make("thread-sticky-active-provider"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 5e0e76730..a3235b362 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -765,6 +765,15 @@ function modelSelectionByProviderToOptions( return Object.keys(result).length > 0 ? result : null; } +function modelOptionsForSelection( + selection: ModelSelection | null | undefined, + model: string, +): ReadonlyArray | undefined { + return selection?.model === model + ? selection.options + : selection?.options?.filter((option) => option.id !== "reasoningEffort"); +} + function cloneModelSelection(selection: ModelSelection): DeepMutable { return { ...selection, @@ -1323,11 +1332,31 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, )) : baseModel; - const modelOptions = - modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? - providerSelectionsFromModelSelection(input.threadModelSelection) ?? - providerSelectionsFromModelSelection(input.projectModelSelection) ?? - null; + const fallbackSelection = input.threadModelSelection ?? input.projectModelSelection; + const selectionsForOptions = activeSelection + ? { + ...input.draft?.modelSelectionByProvider, + [activeSelection.instanceId]: createModelSelection( + activeSelection.instanceId, + selectedModel, + modelOptionsForSelection(activeSelection, selectedModel), + ), + } + : input.draft?.modelSelectionByProvider; + // An optionless draft is an inherited selection, so do not restore the + // previous thread/project effort. Catalog fallback also changes its owner. + const modelOptions = activeSelection + ? modelSelectionByProviderToOptions(selectionsForOptions) + : (modelSelectionByProviderToOptions(selectionsForOptions) ?? + providerSelectionsFromModelSelection( + fallbackSelection + ? createModelSelection( + fallbackSelection.instanceId, + selectedModel, + modelOptionsForSelection(fallbackSelection, selectedModel), + ) + : null, + )); return { selectedModel, @@ -3021,13 +3050,17 @@ const composerDraftStore = create()( return state; } const current = state.stickyModelSelectionByProvider[normalized.instanceId]; - // Model-only picker updates omit options (same contract as - // setModelSelection). Keep the last sticky traits so Fast/Normal - // survives Composer 2 → 2.5 and new chats. + // Model-only picker updates inherit effort for a new model while + // preserving instance-wide speed settings, including Fast/Normal + // across Composer 2 → 2.5 and new chats. const nextSelection = normalized.options !== undefined ? normalized - : createModelSelection(normalized.instanceId, normalized.model, current?.options); + : createModelSelection( + normalized.instanceId, + normalized.model, + modelOptionsForSelection(current, normalized.model), + ); const nextMap: Partial> = { ...state.stickyModelSelectionByProvider, [normalized.instanceId]: nextSelection, @@ -3152,11 +3185,12 @@ const composerDraftStore = create()( // selection as-is. nextMap[normalized.instanceId] = normalized as ModelSelection; } else { - // No options in selection → preserve existing options, update provider+model + // A model change inherits its live effort default; repeating + // the same model keeps its explicit effort and other traits. nextMap[normalized.instanceId] = createModelSelection( normalized.instanceId, normalized.model, - current?.options, + modelOptionsForSelection(current, normalized.model), ); } } diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index ddc5da05f..37b0ca920 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -219,6 +219,23 @@ afterEach(() => { }); describe("showContextMenuFallback", () => { + it("selects an enabled Stop and rejects a disabled Stop", async () => { + const selection = showContextMenuFallback([ + { id: "stop-thread", label: "Stop thread", icon: "square" }, + ]); + const button = findButton("Stop thread"); + expect(button).toBeTruthy(); + button?.dispatchEvent(new FakeDomEvent("click")); + await expect(selection).resolves.toBe("stop-thread"); + + const disabled = showContextMenuFallback([ + { id: "stop-thread", label: "Stop thread", icon: "square", disabled: true }, + ]); + expect(findButton("Stop thread")?.disabled).toBe(true); + findButton("Stop thread")?.dispatchEvent(new FakeDomEvent("click")); + dismissContextMenu(); + await expect(disabled).resolves.toBeNull(); + }); it("renders one separator between menu sections", async () => { const selectionPromise = showContextMenuFallback([ { id: "rename", label: "Rename" }, diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index db14ed8c8..06b08adac 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,6 +4,7 @@ const SVG_NS = "http://www.w3.org/2000/svg"; // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). const ICON_PATHS: Record }>> = { + square: [{ tag: "rect", attrs: { width: "18", height: "18", x: "3", y: "3", rx: "2" } }], archive: [ { tag: "rect", attrs: { width: "20", height: "5", x: "2", y: "3", rx: "1" } }, { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } }, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 793ce7e97..1f6f7c600 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -1,6 +1,7 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; import { + executeAtomQuery, type AtomCommandResult, isAtomCommandInterrupted, settlePromise, @@ -14,9 +15,12 @@ import { useCallback, useMemo } from "react"; import { resolveSnoozePresets } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, + canStopThreadSession, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { orchestrationEnvironment } from "../state/orchestration"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { @@ -94,6 +98,9 @@ export function useThreadActionMenu(input: { deleteThread, markThreadUnread, } = useThreadActions(); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { + reportFailure: false, + }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -139,6 +146,17 @@ export function useThreadActionMenu(input: { pinning: readEnvironmentSupportsPinning(threadRef.environmentId), titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; + const projection = await executeAtomQuery( + appAtomRegistry, + orchestrationEnvironment.v2.threadProjection({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }), + { refresh: true, reportFailure: false }, + ); + const canStopSession = canStopThreadSession( + projection._tag === "Success" ? projection.value.providerSessions : null, + ); const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); const items = buildThreadActionMenuItems({ @@ -151,6 +169,7 @@ export function useThreadActionMenu(input: { canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), + canStopSession, supports, snoozePresets, }); @@ -211,6 +230,15 @@ export function useThreadActionMenu(input: { } return; } + case "stop-thread": + if (!canStopSession) return; + await reportFailure("Failed to stop thread", () => + stopThreadSession({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }), + ); + return; case "settle": await reportFailure("Failed to settle thread", () => settleThread(threadRef)); return; @@ -343,6 +371,7 @@ export function useThreadActionMenu(input: { router, setThreadAutoSettle, settleThread, + stopThreadSession, snoozeThread, threadRef, timestampFormat, diff --git a/apps/web/src/hostStatus.test.ts b/apps/web/src/hostStatus.test.ts new file mode 100644 index 000000000..ad62b9dc8 --- /dev/null +++ b/apps/web/src/hostStatus.test.ts @@ -0,0 +1,299 @@ +import type { HostStatus, HostStatusSnapshot } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { fetchHostStatus, hostStatusMetrics, observeHostStatus } from "./hostStatus"; +import { __resetDesktopPrimaryAuthForTests } from "./environments/primary/desktopAuth"; + +const gib = 1024 ** 3; +const available = { + id: "vps", + status: "available", + cpuUsagePercent: 23, + logicalCpuCount: 4, + occupiedMemoryBytes: 12 * gib, + totalMemoryBytes: 16 * gib, + availableMemoryBytes: 4 * gib, + sampledAt: "2026-09-29T12:00:00.000Z", +} satisfies Extract; +const snapshot: HostStatusSnapshot = { hosts: [available] }; + +class Visibility extends EventTarget { + visibilityState: DocumentVisibilityState = "visible"; + set(value: DocumentVisibilityState) { + this.visibilityState = value; + this.dispatchEvent(new Event("visibilitychange")); + } +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(available.sampledAt)); +}); + +afterEach(() => { + __resetDesktopPrimaryAuthForTests(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("host status metrics", () => { + it("shows whole-number total CPU percentage and available RAM in GiB", () => { + const metrics = hostStatusMetrics(available); + expect(metrics).toMatchObject({ + cpu: "23%", + ram: "4", + cpuHealth: "healthy", + ramHealth: "warning", + health: "warning", + }); + expect(metrics.detail).toContain("CPU 23% of total cores"); + expect(metrics.detail).toContain("Available RAM 4 GiB"); + expect(hostStatusMetrics({ ...available, logicalCpuCount: 128 }).cpu).toBe("23%"); + }); + it.each([ + [0, "healthy"], + [49.9, "healthy"], + [50, "warning"], + [75, "warning"], + [75.1, "elevated"], + [90, "elevated"], + [90.1, "critical"], + [100, "critical"], + ] as const)("colors each metric from unrounded utilization %s", (percent, health) => { + expect( + hostStatusMetrics({ ...available, cpuUsagePercent: percent, availableMemoryBytes: 16 * gib }), + ).toMatchObject({ cpuHealth: health, ramHealth: "healthy", health }); + expect( + hostStatusMetrics({ + ...available, + cpuUsagePercent: 0, + totalMemoryBytes: 1000, + availableMemoryBytes: Math.round(1000 * (1 - percent / 100)), + }), + ).toMatchObject({ cpuHealth: "healthy", ramHealth: health, health }); + }); + it("rounds displayed values without confusing zero or unavailable RAM", () => { + expect( + hostStatusMetrics({ ...available, cpuUsagePercent: 0, availableMemoryBytes: 0 }), + ).toMatchObject({ cpu: "0%", ram: "0", ramHealth: "critical", health: "critical" }); + expect( + hostStatusMetrics({ + ...available, + cpuUsagePercent: 23.76, + availableMemoryBytes: 12.76 * gib, + }), + ).toMatchObject({ cpu: "24%", ram: "13" }); + const { availableMemoryBytes: _memory, ...olderSample } = available; + expect(hostStatusMetrics(olderSample)).toMatchObject({ + cpu: "23%", + ram: "—", + ramHealth: "unavailable", + }); + expect(hostStatusMetrics(undefined)).toMatchObject({ + cpu: "—", + ram: "—", + cpuHealth: "unavailable", + ramHealth: "unavailable", + health: "unavailable", + }); + }); + it("hides values at the thirty-second source freshness boundary", () => { + vi.advanceTimersByTime(29_999); + expect(hostStatusMetrics(available).cpu).toBe("23%"); + vi.advanceTimersByTime(1); + expect(hostStatusMetrics(available)).toMatchObject({ + cpu: "—", + ram: "—", + health: "unavailable", + detail: "Host status sample is stale", + }); + expect(hostStatusMetrics({ id: "home", status: "unavailable", reason: "stale" }).health).toBe( + "unavailable", + ); + }); +}); + +describe("visible host-status polling", () => { + it("polls every ten seconds, pauses hidden pages, and releases listeners/timer on cleanup", async () => { + vi.useFakeTimers(); + const visibility = new Visibility(); + const request = vi.fn().mockResolvedValue(snapshot); + const receive = vi.fn(); + const stop = observeHostStatus(visibility, receive, request); + await vi.advanceTimersByTimeAsync(0); + expect(receive).toHaveBeenLastCalledWith(snapshot); + await vi.advanceTimersByTimeAsync(10_000); + expect(request).toHaveBeenCalledTimes(2); + visibility.set("hidden"); + await vi.advanceTimersByTimeAsync(20_000); + expect(request).toHaveBeenCalledTimes(2); + visibility.set("visible"); + await vi.advanceTimersByTimeAsync(0); + expect(request).toHaveBeenCalledTimes(3); + stop(); + expect(request.mock.calls[0]?.[0].aborted).toBe(true); + receive.mockClear(); + visibility.set("visible"); + await vi.advanceTimersByTimeAsync(20_000); + expect(request).toHaveBeenCalledTimes(3); + expect(receive).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + it("does not overlap requests or deliver a result after disposal", async () => { + vi.useFakeTimers(); + const visibility = new Visibility(); + const receive = vi.fn(); + let resolve!: (value: HostStatusSnapshot) => void; + const request = vi.fn( + () => + new Promise((done) => { + resolve = done; + }), + ); + const stop = observeHostStatus(visibility, receive, request); + await vi.advanceTimersByTimeAsync(30_000); + visibility.set("visible"); + expect(request).toHaveBeenCalledTimes(1); + receive.mockClear(); + stop(); + resolve(snapshot); + await vi.advanceTimersByTimeAsync(0); + expect(receive).not.toHaveBeenCalled(); + }); + it("clears old healthy values when a request fails and recovers on the next poll", async () => { + vi.useFakeTimers(); + const receive = vi.fn(); + const request = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(snapshot); + const stop = observeHostStatus(new Visibility(), receive, request); + await vi.advanceTimersByTimeAsync(10_000); + expect(receive).toHaveBeenLastCalledWith(null); + await vi.advanceTimersByTimeAsync(10_000); + expect(receive).toHaveBeenLastCalledWith(snapshot); + stop(); + }); +}); + +describe("source timestamp expiry", () => { + it("expires a presented sample while the next refresh remains in flight", async () => { + const receive = vi.fn(); + let resolve!: (value: HostStatusSnapshot) => void; + const request = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const stop = observeHostStatus(new Visibility(), receive, request); + await vi.advanceTimersByTimeAsync(29_999); + expect(receive).toHaveBeenLastCalledWith(snapshot); + await vi.advanceTimersByTimeAsync(1); + expect(receive).toHaveBeenLastCalledWith({ + hosts: [{ id: "vps", status: "unavailable", reason: "stale" }], + }); + expect(request).toHaveBeenCalledTimes(2); + resolve(snapshot); + await vi.advanceTimersByTimeAsync(0); + expect(receive).toHaveBeenLastCalledWith({ + hosts: [{ id: "vps", status: "unavailable", reason: "stale" }], + }); + stop(); + expect(vi.getTimerCount()).toBe(0); + }); + it("expires each host from its own source time and rejects already stale samples", async () => { + const receive = vi.fn(); + const older = { ...available, sampledAt: new Date(Date.now() - 25_000).toISOString() }; + const mini = { ...available, id: "mini" as const }; + const stop = observeHostStatus( + new Visibility(), + receive, + vi.fn().mockResolvedValue({ hosts: [older, mini] }), + ); + await vi.advanceTimersByTimeAsync(5_000); + expect(receive).toHaveBeenLastCalledWith({ + hosts: [{ id: "vps", status: "unavailable", reason: "stale" }, mini], + }); + stop(); + }); +}); + +describe("typed gateway request", () => { + it("requests only the primary gateway endpoint with browser credentials", async () => { + vi.stubGlobal("window", { + location: { origin: "https://app.example", href: "https://app.example/" }, + }); + const fetch = vi.fn().mockResolvedValue(Response.json(snapshot)); + vi.stubGlobal("fetch", fetch); + expect(await fetchHostStatus(new AbortController().signal)).toEqual(snapshot); + const request = new Request(fetch.mock.calls[0]?.[0], fetch.mock.calls[0]?.[1]); + expect(request.url).toBe("https://app.example/api/host-status"); + expect(request.credentials).toBe("include"); + }); + it("rejects malformed responses rather than displaying healthy values", async () => { + vi.stubGlobal("window", { + location: { origin: "https://app.example", href: "https://app.example/" }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(Response.json({ hosts: [{ ...available, cpuUsagePercent: -1 }] })), + ); + await expect(fetchHostStatus(new AbortController().signal)).rejects.toThrow(); + }); + it("uses the desktop-managed serving environment and bearer without browser cookies", async () => { + const bearer = vi.fn().mockResolvedValue("synthetic-desktop-token"); + vi.stubGlobal("window", { + location: { origin: "t3code://app", href: "t3code://app/" }, + desktopBridge: { + getLocalEnvironmentBootstraps: () => [ + { + id: "primary", + label: "Fixture server", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }, + ], + getLocalEnvironmentBearerToken: bearer, + }, + }); + const fetcher = vi.fn().mockResolvedValue(Response.json(snapshot)); + vi.stubGlobal("fetch", fetcher); + expect(await fetchHostStatus(new AbortController().signal)).toEqual(snapshot); + const request = new Request(fetcher.mock.calls[0]?.[0], fetcher.mock.calls[0]?.[1]); + expect(request.url).toBe("http://127.0.0.1:3773/api/host-status"); + expect(request.credentials).toBe("omit"); + expect(request.headers.get("authorization")).toBe("Bearer synthetic-desktop-token"); + expect(bearer).toHaveBeenCalledOnce(); + }); + it("does not substitute the renderer machine when the local desktop environment is disabled", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + vi.stubGlobal("window", { + location: { origin: "t3code://app", href: "t3code://app/" }, + desktopBridge: { getLocalEnvironmentEnabled: () => false }, + }); + await expect(fetchHostStatus(new AbortController().signal)).rejects.toThrow(); + expect(fetcher).not.toHaveBeenCalled(); + }); + it.each([401, 403, 404, 500])( + "treats HTTP %s as unavailable rather than retaining healthy data", + async (status) => { + vi.stubGlobal("window", { + location: { origin: "https://remote.example", href: "https://remote.example/" }, + }); + const fetcher = vi + .fn() + .mockResolvedValue(Response.json({ code: "synthetic-error" }, { status })); + vi.stubGlobal("fetch", fetcher); + await expect(fetchHostStatus(new AbortController().signal)).rejects.toThrow(); + const request = new Request(fetcher.mock.calls[0]?.[0], fetcher.mock.calls[0]?.[1]); + expect(request.url).toBe("https://remote.example/api/host-status"); + expect(request.credentials).toBe("include"); + }, + ); +}); diff --git a/apps/web/src/hostStatus.ts b/apps/web/src/hostStatus.ts new file mode 100644 index 000000000..56c725d7c --- /dev/null +++ b/apps/web/src/hostStatus.ts @@ -0,0 +1,152 @@ +import type { HostStatus, HostStatusSnapshot } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { PrimaryEnvironmentHttpClient, layer } from "./environments/primary/httpClient"; +import { primaryEnvironmentHttpLayer } from "./environments/primary/httpLayer"; + +export const HOST_STATUS_NAMES = { vps: "VPS", test: "Test", mini: "Mini", home: "Home" } as const; +export const HOST_STATUS_IDS = ["vps", "test", "mini", "home"] as const; + +const hostStatusRequest = PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => client.hostStatus.snapshot({ headers: {} })), + Effect.timeout("8 seconds"), +); + +export function fetchHostStatus(signal: AbortSignal): Promise { + // Retain RequestInit alongside the client so browser cookie credentials reach the request. + return Effect.runPromise( + hostStatusRequest.pipe( + Effect.provide(layer.pipe(Layer.provideMerge(primaryEnvironmentHttpLayer))), + ), + { signal }, + ); +} + +const HOST_STATUS_MAX_AGE_MS = 30_000; + +function utilizationHealth(percent: number) { + return percent > 90 + ? "critical" + : percent > 75 + ? "elevated" + : percent >= 50 + ? "warning" + : "healthy"; +} + +function freshHostStatus(host: HostStatus, now: number): HostStatus { + if (host.status === "unavailable") return host; + const age = now - Date.parse(host.sampledAt); + return !Number.isFinite(age) || age < 0 || age >= HOST_STATUS_MAX_AGE_MS + ? { id: host.id, status: "unavailable", reason: "stale" } + : host; +} + +export function hostStatusMetrics(sample: HostStatus | undefined) { + const host = sample && freshHostStatus(sample, Date.now()); + if (!host || host.status === "unavailable") { + const reason = !host + ? "Waiting for host status" + : { + not_configured: "Not configured", + upstream_unavailable: "Host status source unavailable", + invalid_response: "Invalid host status response", + stale: "Host status sample is stale", + }[host.reason]; + return { + cpu: "—", + ram: "—", + cpuHealth: "unavailable", + ramHealth: "unavailable", + health: "unavailable", + detail: reason, + } as const; + } + const cpuHealth = utilizationHealth(host.cpuUsagePercent); + const cpu = `${Math.round(host.cpuUsagePercent)}%`; + const availableMemoryBytes = host.availableMemoryBytes; + const ramHealth = + availableMemoryBytes === undefined + ? "unavailable" + : utilizationHealth(100 * (1 - availableMemoryBytes / host.totalMemoryBytes)); + const ram = + availableMemoryBytes === undefined ? "—" : String(Math.round(availableMemoryBytes / 1024 ** 3)); + const health = + availableMemoryBytes === undefined + ? cpuHealth + : utilizationHealth( + Math.max(host.cpuUsagePercent, 100 * (1 - availableMemoryBytes / host.totalMemoryBytes)), + ); + return { + health, + cpuHealth, + ramHealth, + cpu, + ram, + detail: `CPU ${cpu} of total cores. Available RAM ${ram === "—" ? "unavailable" : `${ram} GiB (collector estimate, including reclaimable memory)`}. Colors reflect CPU and RAM utilization.`, + } as const; +} + +export function observeHostStatus( + visibility: Pick, + receive: (snapshot: HostStatusSnapshot | null) => void, + request = fetchHostStatus, +) { + let disposed = false; + let pending = false; + const abort = new AbortController(); + let expiryTimer: ReturnType | undefined; + const clearExpiry = () => { + clearTimeout(expiryTimer); + expiryTimer = undefined; + }; + const publish = (snapshot: HostStatusSnapshot) => { + clearExpiry(); + const now = Date.now(); + const hosts = snapshot.hosts.map((host) => freshHostStatus(host, now)); + receive({ ...snapshot, hosts }); + const deadlines = hosts.flatMap((host) => + host.status === "available" ? [Date.parse(host.sampledAt) + HOST_STATUS_MAX_AGE_MS] : [], + ); + if (deadlines.length > 0) { + expiryTimer = setTimeout( + () => { + if (!disposed && visibility.visibilityState === "visible") + publish({ ...snapshot, hosts }); + }, + Math.min(...deadlines) - now, + ); + } + }; + const refresh = async () => { + if (disposed || pending || visibility.visibilityState !== "visible") return; + pending = true; + try { + const snapshot = await request(abort.signal); + if (!disposed && visibility.visibilityState === "visible") publish(snapshot); + } catch { + if (!disposed) { + clearExpiry(); + receive(null); + } + } finally { + pending = false; + } + }; + const onVisibility = () => { + clearExpiry(); + receive(null); + void refresh(); + }; + const timer = setInterval(() => void refresh(), 10_000); + visibility.addEventListener("visibilitychange", onVisibility); + void refresh(); + return () => { + disposed = true; + clearInterval(timer); + clearExpiry(); + visibility.removeEventListener("visibilitychange", onVisibility); + abort.abort(); + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 65e2cef9c..129be1e3d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1997,8 +1997,28 @@ code { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); } +/* Size top-level expanded tables to their content, capped by the conversation + pane. The row follows ChatCanvas's lane insets; surrounding text retains the + reading width, and nested tables stay inside their markdown parent. */ +[data-timeline-root] .chat-markdown > .chat-markdown-table-container[data-expanded="true"] { + position: relative; + left: 50%; + width: max-content; + max-width: calc(100cqw - 0.5rem); + margin-inline: 0; + transform: translateX(-50%); +} + +.chat-markdown [data-markdown-table-scroll] { + max-height: min(24rem, 60dvh); +} + +.chat-markdown .chat-markdown-table-container[data-expanded="true"] [data-markdown-table-scroll] { + max-height: min(30rem, 75dvh); +} + /* Collapsed (default): single-line cells truncate so arbitrary chat content - cannot blow the column out; the footer toggle expands cells to wrap. */ + cannot blow the column out; the footer toggle expands the table and wraps cells. */ .chat-markdown .chat-markdown-table-container[data-expanded="false"] th, .chat-markdown .chat-markdown-table-container[data-expanded="false"] td { overflow: hidden; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index e6fab4271..9d9a4ca0c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WelcomeRouteImport } from './routes/welcome' +import { Route as VoiceReviewRouteImport } from './routes/voice-review' import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' @@ -32,6 +33,7 @@ import { Route as SettingsArchivedRouteImport } from './routes/settings.archived import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' +import { Route as ChatConversationsRouteImport } from './routes/_chat.conversations' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -40,6 +42,11 @@ const WelcomeRoute = WelcomeRouteImport.update({ path: '/welcome', getParentRoute: () => rootRouteImport, } as any) +const VoiceReviewRoute = VoiceReviewRouteImport.update({ + id: '/voice-review', + path: '/voice-review', + getParentRoute: () => rootRouteImport, +} as any) const UsageRoute = UsageRouteImport.update({ id: '/usage', path: '/usage', @@ -150,6 +157,11 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ path: '/pull-requests', getParentRoute: () => ChatRoute, } as any) +const ChatConversationsRoute = ChatConversationsRouteImport.update({ + id: '/conversations', + path: '/conversations', + getParentRoute: () => ChatRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -168,7 +180,9 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/voice-review': typeof VoiceReviewRoute '/welcome': typeof WelcomeRoute + '/conversations': typeof ChatConversationsRoute '/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -193,7 +207,9 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/voice-review': typeof VoiceReviewRoute '/welcome': typeof WelcomeRoute + '/conversations': typeof ChatConversationsRoute '/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -221,7 +237,9 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/voice-review': typeof VoiceReviewRoute '/welcome': typeof WelcomeRoute + '/_chat/conversations': typeof ChatConversationsRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -250,7 +268,9 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/voice-review' | '/welcome' + | '/conversations' | '/pull-requests' | '/projects/$projectKey' | '/settings/appearance' @@ -275,7 +295,9 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/voice-review' | '/welcome' + | '/conversations' | '/pull-requests' | '/projects/$projectKey' | '/settings/appearance' @@ -302,7 +324,9 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/voice-review' | '/welcome' + | '/_chat/conversations' | '/_chat/pull-requests' | '/projects/$projectKey' | '/settings/appearance' @@ -330,6 +354,7 @@ export interface RootRouteChildren { PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute + VoiceReviewRoute: typeof VoiceReviewRoute WelcomeRoute: typeof WelcomeRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } @@ -343,6 +368,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WelcomeRouteImport parentRoute: typeof rootRouteImport } + '/voice-review': { + id: '/voice-review' + path: '/voice-review' + fullPath: '/voice-review' + preLoaderRoute: typeof VoiceReviewRouteImport + parentRoute: typeof rootRouteImport + } '/usage': { id: '/usage' path: '/usage' @@ -497,6 +529,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatPullRequestsRouteImport parentRoute: typeof ChatRoute } + '/_chat/conversations': { + id: '/_chat/conversations' + path: '/conversations' + fullPath: '/conversations' + preLoaderRoute: typeof ChatConversationsRouteImport + parentRoute: typeof ChatRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -515,6 +554,7 @@ declare module '@tanstack/react-router' { } interface ChatRouteChildren { + ChatConversationsRoute: typeof ChatConversationsRoute ChatPullRequestsRoute: typeof ChatPullRequestsRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute @@ -522,6 +562,7 @@ interface ChatRouteChildren { } const ChatRouteChildren: ChatRouteChildren = { + ChatConversationsRoute: ChatConversationsRoute, ChatPullRequestsRoute: ChatPullRequestsRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, @@ -574,6 +615,7 @@ const rootRouteChildren: RootRouteChildren = { PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, + VoiceReviewRoute: VoiceReviewRoute, WelcomeRoute: WelcomeRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } diff --git a/apps/web/src/routes/_chat.conversations.tsx b/apps/web/src/routes/_chat.conversations.tsx new file mode 100644 index 000000000..2bf00ed93 --- /dev/null +++ b/apps/web/src/routes/_chat.conversations.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConversationLibraryPage } from "../components/conversations/ConversationLibraryPage"; + +export const Route = createFileRoute("/_chat/conversations")({ + component: ConversationLibraryPage, +}); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index b67f79234..bb908e70b 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -96,6 +96,7 @@ import { import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic"; import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; +import { PullRequestCiStatusPopover } from "../components/pullRequest/PullRequestCiStatusPopover"; import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { PullRequestFiltersMenu, @@ -1752,6 +1753,10 @@ function PullRequestsRouteView() { className="absolute top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]" data-workspace-titlebar-controls > + {panelToggleControls} ); diff --git a/apps/web/src/routes/voice-review.tsx b/apps/web/src/routes/voice-review.tsx new file mode 100644 index 000000000..89db01403 --- /dev/null +++ b/apps/web/src/routes/voice-review.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { VoiceReviewPage } from "../components/voiceReview/VoiceReviewPage"; + +export const Route = createFileRoute("/voice-review")({ component: VoiceReviewPage }); diff --git a/apps/web/src/state/conversations.ts b/apps/web/src/state/conversations.ts new file mode 100644 index 000000000..c11afd3a5 --- /dev/null +++ b/apps/web/src/state/conversations.ts @@ -0,0 +1,90 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { LibraryReply, LibraryRequest } from "@t3tools/contracts/conversationLibrary"; +import { fetchEnvironmentConversationLibraryRequest } from "@t3tools/client-runtime/conversations"; +import { EnvironmentSupervisor } from "@t3tools/client-runtime/connection"; +import * as ManagedRelay from "@t3tools/client-runtime/relay"; +import { createEnvironmentCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +class ConversationLibraryConnectionNotReadyError extends Data.TaggedError( + "ConversationLibraryConnectionNotReadyError", +)<{ readonly message: string }> {} + +/** + * Run the conversation-library HTTP adapter inside the selected environment's + * supervisor. This keeps requests on the current prepared credential and + * supports cookie, bearer, and relay DPoP authentication through the shared + * runtime HTTP client. + */ +export const requestConversationLibrary = createEnvironmentCommand(connectionAtomRuntime, { + label: "web-conversation-library:request", + execute: (request: LibraryRequest, _registry, environmentId) => + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new ConversationLibraryConnectionNotReadyError({ + message: "The environment HTTP connection is not ready.", + }); + } + if (prepared.value.environmentId !== EnvironmentId.make(environmentId)) { + return yield* new ConversationLibraryConnectionNotReadyError({ + message: "The environment connection changed. Refresh before trying again.", + }); + } + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelay.ManagedRelayDpopSigner); + return yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared.value, + request, + signer, + }); + }), +}); + +export function conversationLibraryReplyMatchesRequest( + request: LibraryRequest, + reply: LibraryReply, +): boolean { + switch (request.kind) { + case "hello": + return reply.kind === "hello"; + case "accounts": + return reply.kind === "accounts"; + case "createAccount": + return reply.kind === "account"; + case "preview": + return reply.kind === "preview"; + case "import": + return reply.kind === "imported"; + case "list": + return reply.kind === "list"; + case "detail": + return reply.kind === "detail"; + case "update": + return reply.kind === "updated"; + case "selectSnapshot": + return reply.kind === "updated"; + case "remove": + return reply.kind === "removed"; + } +} + +export function conversationLibraryErrorMessage(cause: unknown): string { + const error = cause instanceof Error ? cause : null; + const code = error && "code" in error ? String(error.code) : null; + if (code === "unsupported") { + return "This environment server does not support the Conversation Library."; + } + if (code === "forbidden") { + return "This account does not have permission to use the Conversation Library."; + } + if (code === "conflict") { + return "The library changed while this request was running. Refresh to reconcile the current state."; + } + return error?.message.trim() || "The Conversation Library request failed."; +} diff --git a/apps/web/src/state/tokenAccounting.test.ts b/apps/web/src/state/tokenAccounting.test.ts new file mode 100644 index 000000000..004ffe0c8 --- /dev/null +++ b/apps/web/src/state/tokenAccounting.test.ts @@ -0,0 +1,90 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + accountingUnavailableMessage, + formatAccountingCount, + formatAccountingMetric, + selectAccountingEnvironment, + type AccountingEnvironment, +} from "./tokenAccounting"; + +function environment( + id: string, + overrides: Partial = {}, +): AccountingEnvironment { + return { + environmentId: EnvironmentId.make(id), + label: id, + primary: false, + connected: true, + supported: true, + ...overrides, + }; +} + +describe("saved accounting environment selection", () => { + it("prefers a capable connected primary and honors an explicit eligible selection", () => { + const primary = environment("primary", { primary: true }); + const remote = environment("remote"); + expect(selectAccountingEnvironment([remote, primary], null)).toBe(primary); + expect(selectAccountingEnvironment([remote, primary], remote.environmentId)).toBe(remote); + }); + + it("uses the sole eligible environment and requires a choice when several have no primary", () => { + const first = environment("first"); + const second = environment("second"); + expect(selectAccountingEnvironment([first], null)).toBe(first); + expect(selectAccountingEnvironment([first, second], null)).toBeNull(); + expect(selectAccountingEnvironment([first, second], second.environmentId)).toBe(second); + }); + + it("excludes disconnected and unsupported environments even when selected or primary", () => { + const disconnected = environment("disconnected", { primary: true, connected: false }); + const old = environment("old", { supported: false }); + const eligible = environment("eligible"); + expect(selectAccountingEnvironment([disconnected, old], disconnected.environmentId)).toBeNull(); + expect(selectAccountingEnvironment([disconnected, old, eligible], old.environmentId)).toBe( + eligible, + ); + }); +}); + +describe("saved accounting missingness", () => { + it("distinguishes known zero, partial known zero and an entirely unknown metric", () => { + expect( + formatAccountingMetric({ total: 0, known_sum: 0, known_requests: 1, missing_requests: 0 }), + ).toBe("0"); + expect( + formatAccountingMetric({ total: null, known_sum: 0, known_requests: 1, missing_requests: 1 }), + ).toBe("Known: 0 · total unknown"); + expect( + formatAccountingMetric({ + total: null, + known_sum: null, + known_requests: 0, + missing_requests: 2, + }), + ).toBe("Unknown"); + expect( + formatAccountingMetric({ + total: null, + known_sum: 42, + known_requests: 1, + missing_requests: 1, + }), + ).toBe("Known: 42 · total unknown"); + }); + + it("formats large counts without claiming additional precision", () => { + expect(formatAccountingCount(5_850_000)).toBe("5.85M"); + expect(formatAccountingCount(30_500_000_000)).toBe("30.5B"); + expect(formatAccountingCount(400_000_000)).toBe("400M"); + }); + + it("uses a fixed message for a typed identity failure", () => { + expect( + accountingUnavailableMessage({ status: "invalid", reason: "report_identity_mismatch" }), + ).toBe("The saved report did not pass its identity check."); + }); +}); diff --git a/apps/web/src/state/tokenAccounting.ts b/apps/web/src/state/tokenAccounting.ts new file mode 100644 index 000000000..0005c9d32 --- /dev/null +++ b/apps/web/src/state/tokenAccounting.ts @@ -0,0 +1,128 @@ +import type { + EnvironmentId, + TokenAccountingMetric, + TokenAccountingReadInput, + TokenAccountingReadResult, + TokenAccountingUnavailable, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { useLayoutEffect, useRef, useState } from "react"; + +export interface AccountingEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly primary: boolean; + readonly connected: boolean; + readonly supported: boolean; +} + +export function selectAccountingEnvironment( + environments: readonly AccountingEnvironment[], + selected: EnvironmentId | null, +): AccountingEnvironment | null { + const eligible = environments.filter( + (environment) => environment.connected && environment.supported, + ); + return ( + eligible.find((environment) => environment.environmentId === selected) ?? + eligible.find((environment) => environment.primary) ?? + (eligible.length === 1 ? eligible[0]! : null) + ); +} + +export function formatAccountingCount(value: number): string { + if (value >= 1e9) + return `${(value / 1e9).toLocaleString("en-US", { maximumFractionDigits: 1 })}B`; + if (value >= 1e6) + return `${(value / 1e6).toLocaleString("en-US", { maximumFractionDigits: 2 })}M`; + return value.toLocaleString("en-US"); +} + +export function formatAccountingMetric(metric: TokenAccountingMetric): string { + if (metric.total !== null) return formatAccountingCount(metric.total); + if (metric.known_sum !== null) + return `Known: ${formatAccountingCount(metric.known_sum)} · total unknown`; + return "Unknown"; +} + +export const ACCOUNTING_TRANSPORT_ERROR = + "The saved report could not be read. Try again when connected."; + +const UNAVAILABLE_MESSAGES: Record = { + reader_unconfigured: "A saved accounting reader has not been configured for this environment.", + report_unconfigured: "A saved accounting report has not been selected for this environment.", + host_binding_unverified: + "The saved accounting reader is unavailable until its host binding is verified.", + configured_report_missing: "The configured saved report is missing.", + report_invalid: "The saved report did not pass validation.", + report_identity_mismatch: "The saved report did not pass its identity check.", + configured_report_id_mismatch: "The saved report does not match the configured report ID.", + projection_invalid: "The saved report could not be represented as a valid accounting summary.", + report_schema_unsupported: "This saved report format is not supported.", + identity_algorithm_unsupported: "This saved report identity format is not supported.", + input_too_large: "The saved report exceeds the reader size limit.", + projection_too_large: "The saved accounting summary exceeds the response size limit.", + collection_limit_exceeded: "The saved report exceeds the supported collection limits.", + reader_timeout: "The saved accounting reader did not finish in time.", + reader_failed: "The saved accounting reader could not read the report.", +}; + +export function accountingUnavailableMessage(result: TokenAccountingUnavailable): string { + return UNAVAILABLE_MESSAGES[result.reason]; +} + +export interface AccountingReadTarget { + readonly environmentId: EnvironmentId; + readonly generation: number; +} + +type AccountingRead = (target: { + readonly environmentId: EnvironmentId; + readonly input: TokenAccountingReadInput; +}) => Promise>; + +type AccountingReadState = + | { readonly phase: "idle" | "reading" | "error" } + | { readonly phase: "observed"; readonly result: TokenAccountingReadResult }; + +export function useSavedTokenAccounting(target: AccountingReadTarget | null, read: AccountingRead) { + const key = target === null ? null : `${target.environmentId}:${target.generation}`; + const [state, setState] = useState({ phase: "idle" }); + const lifetime = useRef({ key: null as string | null, attempt: 0, reading: false }); + + useLayoutEffect(() => { + lifetime.current.key = key; + lifetime.current.attempt += 1; + lifetime.current.reading = false; + setState({ phase: "idle" }); + return () => { + // A result belongs to one connection generation and one mounted panel. + lifetime.current.key = null; + lifetime.current.attempt += 1; + lifetime.current.reading = false; + }; + }, [key]); + + async function load() { + if (target === null || lifetime.current.key !== key || lifetime.current.reading) return; + const attempt = ++lifetime.current.attempt; + lifetime.current.reading = true; + setState({ phase: "reading" }); + const current = () => lifetime.current.key === key && lifetime.current.attempt === attempt; + try { + const result = await read({ environmentId: target.environmentId, input: {} }); + if (!current()) return; + setState( + result._tag === "Success" + ? { phase: "observed", result: result.value } + : { phase: "error" }, + ); + } catch { + if (current()) setState({ phase: "error" }); + } finally { + if (current()) lifetime.current.reading = false; + } + } + + return { state, load }; +} diff --git a/assets/jones-code/jc-macos-1024.png b/assets/jones-code/jc-macos-1024.png new file mode 100644 index 000000000..8b71e56f9 Binary files /dev/null and b/assets/jones-code/jc-macos-1024.png differ diff --git a/assets/jones-code/jc-mark.svg b/assets/jones-code/jc-mark.svg new file mode 100644 index 000000000..daf9b219f --- /dev/null +++ b/assets/jones-code/jc-mark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docs/internals/checkpoint-safety.md b/docs/internals/checkpoint-safety.md new file mode 100644 index 000000000..db767b1c6 --- /dev/null +++ b/docs/internals/checkpoint-safety.md @@ -0,0 +1,15 @@ +# Checkpoint safety + +Checkpoint snapshots stage working files into hidden Git refs. Capturing the physical primary +checkout could retain unrelated dirty files in the shared repository even when they were never +committed on a visible branch. Capture therefore requires a linked Git worktree, independent of +branch name or working-tree cleanliness. + +The boundary belongs in `GitVcsDriver.checkpoints.captureCheckpoint` so all callers are covered. +It compares the per-worktree and common Git directories before creating an index, commit, or ref, +and returns `VcsPrimaryCheckoutCheckpointError` when they identify the primary checkout. +Checkpoint reads and restoration retain their existing behavior. + +Tests use disposable repositories with linked worktrees. A fixture's index belongs to its +per-worktree Git directory; checkpoint refs belong to the common Git directory. Resolve those +paths through Git instead of assuming `.git` is a directory in the workspace. diff --git a/docs/internals/provider-runtime-identity.md b/docs/internals/provider-runtime-identity.md new file mode 100644 index 000000000..b252e0070 --- /dev/null +++ b/docs/internals/provider-runtime-identity.md @@ -0,0 +1,47 @@ +# Provider runtime identity + +A requested model is routing intent. An observed model describes native evidence +from the process that serves a particular conversation. Keep them separate even +when their values happen to match. Neither provider settings, authentication +metadata nor a model catalog establishes observed backend, model, account or tier. +The [identity contract](../../packages/contracts/src/providerRuntimeIdentity.ts) +uses unknown and unavailable states so missing evidence stays visible. + +Identity belongs to a provider thread, not a shared session. One Codex app-server +can serve native conversations with different models. Its process generation is +reserved before launch and captured by its callbacks; a logical session ID or an +idle-timer generation cannot substitute for that incarnation. Typed Codex +thread-open responses provide model/backend/tier evidence. Native reroute +notifications update only observed model. Claude SDK init provides model evidence only. Neither boundary safely +binds an account to the process. + +[Session management](../../apps/server/src/orchestration-v2/ProviderSessionManager.ts) +publishes the successful binding boundary before releasing observations. A failed +or interrupted candidate cannot become current. Replacement invalidates the old +issuer. Codex token rotation resumes the same native cursor with the current +request before sending the next prompt. It refuses replacement while shared +active or background work remains. A capacity retry remains attached to its original prompt and +producer; runtime drift cancels it rather than replaying the prompt. + +Claude rewind closes the old query and preserves or resets its continuation. +The replacement generation exists only when the next send actually launches a +query. Rollback itself is not a replacement-process observation. Historical +identity in JSON likewise records past evidence; replay never activates a producer. + +The [event sink](../../apps/server/src/orchestration-v2/EventSink.ts) validates the +native binding inside the same transaction as the projection write. Evidence +revision can advance within an incarnation. With a pinned generation, later +revisions are valid only for the same application thread, provider thread, session, +driver, instance and native conversation; revision regression remains invalid. +Without a generation, a captured revision must match exactly. Late snapshots +preserve newer requested configuration and observations. A start captured before an +observation-only revision advance may still commit when the exact previous +instance, driver, model and explicit tier remain current. A requested-configuration +change rejects that stale writer even when the process generation is unchanged. + +Full and detail views retain each provider thread's own identity. Shell identity +comes only from the exact active provider thread of that application thread. Old snapshots may omit identity, and legacy Jones migration002 remains compatibility +storage rather than evidence of a live V2 runtime. Explicit service-tier options +are recorded as requested; normalization of the fast-mode alias remains deferred. +A native launch followed by binding-publication failure remains an unknown effect: +fail visibly and stop replay. Automatic recovery of that boundary is deferred. diff --git a/docs/operations/observability.md b/docs/operations/observability.md index b83a9015a..35769cd11 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -668,3 +668,72 @@ Before you take one: swapping, it can make the problem worse or crash the server. - The file contains everything in server memory, including tokens, secrets, and thread content. Do not share it publicly. Delete it when you are done, because storage cleanup does not remove it. + +## Netdata host status + +Configure Netdata API v3 connections in the **Jones Code server process environment** +to expose host CPU and RAM through authenticated `GET /api/host-status`. The fixed +slots are `vps`, `test`, `mini`, and `home`. The browser requests the primary +serving environment's gateway; it does not inspect the client machine or receive +collector URLs or tokens. Desktop uses its primary environment's bearer route; +when its local environment is disabled, no local-machine fallback is used. + +A shared parent collector can serve all mapped nodes. Each shared slot requires +an explicit machine GUID from `/api/v3/nodes`; unmapped slots stay unavailable. +For example, these placeholders configure mappings without adopting a live collector: + +```sh +T3CODE_NETDATA_URL=http://127.0.0.1:19999 +T3CODE_NETDATA_VPS_NODE='' +T3CODE_NETDATA_TEST_NODE='' +T3CODE_NETDATA_MINI_NODE='' +T3CODE_NETDATA_HOME_NODE='' +``` + +| Variable | Meaning | +| --------------------------- | --------------------------------------------------------------------------------------------- | +| `T3CODE_NETDATA_URL` | Shared parent base URL reachable from the serving environment. | +| `T3CODE_NETDATA_TOKEN` | Optional bearer token for the shared parent, supplied through protected server configuration. | +| `T3CODE_NETDATA__NODE` | Exact machine GUID for a fixed slot. | +| `T3CODE_NETDATA__URL` | Per-slot endpoint override; without `_NODE`, it must report exactly one node. | +| `T3CODE_NETDATA__TOKEN` | Per-slot bearer token. A URL override never inherits the shared token. | + +Tokens authenticate API reads; they are not Netdata streaming keys. Keep collector +configuration out of browser build variables. Changing process launch settings +requires a separately authorized server restart; source changes do not activate +or reconfigure a collector. + +The endpoint requires an authenticated session with `orchestration:read` and +returns `cache-control: no-store`. Unconfigured slots return `not_configured`; +invalid data, stale source samples, and upstream failures produce explicit +unavailable states per host. One failed host does not hide the others. The gateway +rejects redirects, URL credentials, unsupported response shapes, and oversized +bodies, with a three-second deadline per host. + +Queries select the configured node's GUID and a five-second window aggregated to +one point. Logical CPU count and total RAM come from that node's metadata. CPU +uses `system.cpu` percentage units and the complete macOS or Linux dimension set. +The executing dimensions are summed; idle, iowait, and steal are excluded, and +Linux guest dimensions are counted once. The result is 0–100% of total host CPU +capacity, without dividing by logical core count. + +Occupied RAM remains on the wire for compatibility: total bytes minus native +`system.ram/free` MiB, including reclaimable memory. The optional +`availableMemoryBytes` is collected separately: Linux `mem.available/avail`, or +the macOS collector estimate of free + speculative + inactive + purgeable pages. +Missing or invalid available-memory data leaves CPU usable and available RAM +unavailable; free RAM is never relabeled as available RAM. Every reported byte +value is integral and bounded by total RAM. + +The header shows compact whole numbers such as `VPS · 24% · 41`: total-host CPU +percentage and available RAM in GiB. Tooltips retain units and collector-estimate +meaning. CPU and RAM colors use their unrounded utilization, and either can raise +the badge background; the RAM color is not a direct measurement of memory +pressure. The breadcrumb receives its natural width first; complete badges fit +into the remaining space in VPS, Test, Mini, Home order, with hidden badges +removed from keyboard focus. + +Source timestamps older than 30 seconds become unavailable. The client expires +presented values at 30 seconds even while another request is pending, polls every +ten seconds while visible, clears samples when hidden or after failures, and +releases timers and aborts its request on unmount. diff --git a/docs/user/composer.md b/docs/user/composer.md index 8cb8d7805..fdd403abc 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,9 @@ becomes an attachment when inserting it would exceed the message limit. On a hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere to keep a large paste editable in the composer instead. +You can send the first message with **New worktree** selected while branches are still loading. +T3 Code uses the repository's default base unless you choose a branch. + ## Attach files Attach up to 100 files per message. Each image can be up to 10 MiB, with at most @@ -35,8 +38,8 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin ## Send while the agent is working On web and desktop, choose **Settings → General → Follow-up behavior** to queue -new messages for a later turn or steer the running turn immediately. The setting -applies to this client; already queued messages keep their place. Queued messages +new messages for the next safe tool boundary or steer the running turn immediately. +The setting applies to this client; already queued messages keep their place. Queued messages are saved on the server and can be edited, reordered, or removed above the composer. `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux uses the opposite action: it steers when your default is Queue and queues when your default is Steer. @@ -156,7 +159,17 @@ On web and desktop, the composer shows **Interrupt** while the agent is working empty. Adding text or attachments replaces it with a steer arrow. Click it to send a message into the active turn, or press `Enter` on desktop. Hold `Cmd` on macOS or `Ctrl` on Windows and Linux to switch the button to a queue icon. Click while holding that key, or press `Cmd+Enter` or -`Ctrl+Enter` on desktop, to queue the message for after the active turn. +`Ctrl+Enter` on desktop, to queue the message for the next safe tool boundary on supporting servers. +Older servers keep queued messages for the next turn. + +On supporting servers, Queue sends compatible messages after the current foreground tool calls finish, +even when you switch threads or disconnect the client. It waits while the agent needs an +approval or answer. Providers that cannot accept input without restarting, commands such as +`/compact`, and messages that change the provider, model, or modes wait for a later turn instead. +At a tool boundary, messages follow their selected queue order. Later messages wait for earlier +delivery to succeed, including any retries. +Failed or interrupted tools do not release the queue on their own; it waits for the next +qualifying tool completion or the end of the turn. Stopping the thread keeps its queue held. Queued messages appear above the composer. Rows show a thumbnail of any attached image alongside the text. Drag a row by its handle to reorder it, use the handle's arrow keys, promote the message @@ -174,6 +187,20 @@ you had typed in the composer before starting the edit is restored afterwards. I message starts or is removed while you are editing, the edit ends: changed content moves into the composer when it is empty, and is discarded otherwise. +## Review external voice prompts + +On web and desktop, open **Voice review** from the sidebar and choose the +environment connected to your voice source. Voice review requires that environment +to be configured; it does not record audio. Held prompts release after their +countdown. Pause a prompt when you need more time to review it. Double-click its +text, or choose **Edit**, to edit it before release. + +Editing holds release until you save or cancel, and both leave the prompt paused. +Resume the countdown when you are ready, or use **Save and send** to release the +saved text immediately. Closing the page does not resume an edit hold. If a result +is uncertain, check the current state before acting again. Released means handed +off for processing; it does not mean an agent has started. + ## Commands and skills Type `/` for commands or `$` to add a skill from the selected environment and diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index eb7c4e6a9..493107ad4 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -134,6 +134,12 @@ sending an answer or restarting the agent. Settling also closes the thread's terminals that wait at an idle prompt, and keeps their output. A terminal that runs a command, such as a dev server, stays open. +On web and desktop, choose **Stop thread** from the sidebar or chat header menu +to stop the thread's agent sessions while keeping its conversation, pin, and +settlement state. Sending another message starts a session again. Stop leaves +terminals open. Use **Settle thread** to move finished work out of the active +list, or **Delete** to permanently clear the conversation history. + On web and desktop, press a thread's **Settle** button and drag up or down to settle every thread in that section between it and the one you release on. The **Un-settle** and **Wake** buttons work the same way in their sections. @@ -182,6 +188,17 @@ and copying a thread reference. A copied reference uses the thread's pull reques link when available, otherwise its thread ID. See [keybindings](./keybindings.md) for custom configuration. +## Block messages from other threads + +Ask the agent to block incoming messages for its thread, or for another thread +in the same project. The block stays in place across restarts. You can still +message the thread directly, and task-completion notifications and work already +accepted into its queue continue normally. + +To allow messages again, ask the agent in the blocked thread to unblock itself. +Agents in other threads cannot remove its block. New forks and subagent threads +start with their own unblocked setting. + ## Inspect agent work **Limited** means the provider stopped on a usage or rate limit. The conversation diff --git a/knip.jsonc b/knip.jsonc index 386b71089..5d3817314 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -34,6 +34,11 @@ "scripts/update-test-shard-weights.ts", "scripts/verify-background-live.ts", "src/provider/testFixtures/*.mjs", + // Staged native creation substrate is consumed by the deferred L39 execution slice. + // Analyzer entries preserve these APIs without registering runtime authority or launch. + "src/nativeCreation/NativeCreationPreparation.ts", + "src/nativeCreation/NativeCreationAuthority.ts", + "src/nativeCreation/NativeCreationRepositorySqlite.ts", ], // Keep the transitive Effect runtime pinned for standalone npm installs. // The Vite+ web build prerequisite. @@ -55,6 +60,11 @@ "src/preview-pip-preload.ts!", "src/mac-permission-preload.ts!", "src/preview/Annotation.css!", + // The namespace launcher stages and loads these modules by filename. + "scripts/ui-evidence/probe.mjs", + "scripts/ui-evidence/runner.mjs", + "scripts/ui-evidence/scenarios/example.mjs", + "scripts/ui-evidence/scenarios/sidebar-rename.mjs", ], // The injected runtime resolves Playwright by string; release tooling runs // electron-builder from this workspace, outside its package scripts. diff --git a/package.json b/package.json index e4b92d711..afadc28d3 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "test:perf:v2-wire": "vp test run apps/server/src/orchestration-v2/WireProjection.test.ts apps/server/src/orchestration-v2/threadHistoryPaging.test.ts apps/server/src/orchestration-v2/ThreadStream.test.ts apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts apps/server/src/orchestration-v2/ShellStream.test.ts apps/server/src/orchestration-v2/CommandPolicy.test.ts apps/server/src/orchestration-v2/runtimeLayer.test.ts apps/server/src/ws.test.ts packages/contracts/src/environmentHttp.test.ts packages/contracts/src/orchestrationV2.test.ts packages/client-runtime/src/operations/commands.test.ts packages/client-runtime/src/operations/commands.performance.test.ts packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts packages/client-runtime/src/state/threadHistoryController.test.ts packages/client-runtime/src/state/threadHistoryMerge.test.ts packages/client-runtime/src/state/threadDetail.test.ts packages/client-runtime/src/state/threads-sync.test.ts packages/client-runtime/src/state/shellReducer.test.ts", "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", + "ui:evidence": "node apps/desktop/scripts/ui-evidence.mjs", + "test:ui-evidence": "node --test --test-concurrency=1 apps/desktop/scripts/ui-evidence/*.test.mjs", "fmt": "vp fmt", "fmt:check": "vp fmt --check", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index b812495cb..1627ecfeb 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,14 @@ "private": true, "type": "module", "exports": { + "./conversations": { + "types": "./src/conversations/index.ts", + "default": "./src/conversations/index.ts" + }, + "./voice-review": { + "types": "./src/voice-review.ts", + "default": "./src/voice-review.ts" + }, "./composerThreadItems": { "types": "./src/composerThreadItems.ts", "default": "./src/composerThreadItems.ts" diff --git a/packages/client-runtime/src/conversations/http.test.ts b/packages/client-runtime/src/conversations/http.test.ts new file mode 100644 index 000000000..74c9fd111 --- /dev/null +++ b/packages/client-runtime/src/conversations/http.test.ts @@ -0,0 +1,192 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { fetchEnvironmentConversationLibraryRequest } from "./http.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Primary", + httpBaseUrl: "https://environment.example.test/base", + wsBaseUrl: "wss://environment.example.test", +}); + +const prepared = ( + httpAuthorization: PreparedConnection["httpAuthorization"] = null, +): PreparedConnection => ({ + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization, + target: TARGET, +}); + +const hello = { + kind: "hello", + protocol: "t3.conversation-library.v1", + revision: 1, + canWrite: true, + capture: "not-enabled", +}; + +const fetchWithJson = ( + body: unknown, + status = 200, + onRequest?: (request: RequestInfo | URL, init: RequestInit) => void, +): typeof fetch => + ((request, init) => { + onRequest?.(request, init ?? {}); + return Promise.resolve(Response.json(body, { status })); + }) satisfies typeof fetch; + +describe("conversation library HTTP transport", () => { + it.effect("uses cookie credentials for the primary environment and posts the typed request", () => + Effect.gen(function* () { + const calls: Array = []; + const result = yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared(), + request: { kind: "hello" }, + signer: Option.none(), + }).pipe( + Effect.provide( + remoteHttpClientLayer( + fetchWithJson(hello, 200, (request, init) => calls.push([request, init])), + ), + ), + ); + + expect(result).toEqual(hello); + expect(calls).toHaveLength(1); + const [request, init] = calls[0]!; + expect(String(request)).toBe("https://environment.example.test/api/conversation-library"); + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("include"); + const body = + typeof init.body === "string" + ? init.body + : init.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : ""; + expect(body).toContain('"kind":"hello"'); + }), + ); + + it.effect("sends bearer authorization without cookie credentials", () => + Effect.gen(function* () { + let requestInit: RequestInit | undefined; + yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared({ _tag: "Bearer", token: "environment-token" }), + request: { kind: "hello" }, + signer: Option.none(), + }).pipe( + Effect.provide( + remoteHttpClientLayer( + fetchWithJson(hello, 200, (_request, init) => { + requestInit = init; + }), + ), + ), + ); + + expect(new Headers(requestInit?.headers).get("authorization")).toBe( + "Bearer environment-token", + ); + expect(requestInit?.credentials).toBeUndefined(); + }), + ); + + it.effect("binds DPoP proofs to the conversation library POST URL", () => + Effect.gen(function* () { + let proofInput: Parameters[0] | undefined; + let requestInit: RequestInit | undefined; + const signer: ManagedRelayDpopSigner["Service"] = { + thumbprint: Effect.succeed("thumbprint"), + createProof: (input) => { + proofInput = input; + return Effect.succeed("proof-value"); + }, + }; + const remoteAuthorization = RemoteEnvironmentAuthorization.of({ + authorizeBearer: () => Effect.die("unused"), + authorizeDpop: () => Effect.die("unused"), + authorizeDpopHttp: () => + Effect.succeed({ + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "relay-token", + expiresAtEpochMs: 10_000, + }, + }), + }); + + yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared({ _tag: "Dpop", accessToken: "relay-token", expiresAtEpochMs: 10_000 }), + request: { kind: "hello" }, + signer: Option.some(signer), + }).pipe( + Effect.provideService(RemoteEnvironmentAuthorization, remoteAuthorization), + Effect.provide( + remoteHttpClientLayer( + fetchWithJson(hello, 200, (_request, init) => { + requestInit = init; + }), + ), + ), + ); + + expect(proofInput).toEqual({ + method: "POST", + url: "https://environment.example.test/api/conversation-library", + accessToken: "relay-token", + }); + expect(new Headers(requestInit?.headers).get("authorization")).toBe("DPoP relay-token"); + expect(new Headers(requestInit?.headers).get("dpop")).toBe("proof-value"); + expect(requestInit?.credentials).toBeUndefined(); + }), + ); + + it.effect("preserves the unsupported feature code returned by an older server", () => + Effect.gen(function* () { + const unsupported = { + kind: "error", + code: "unsupported", + message: "Conversation library is not available on this server.", + traceId: "trace-old-server", + }; + const error = yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared(), + request: { kind: "hello" }, + signer: Option.none(), + }).pipe(Effect.provide(remoteHttpClientLayer(fetchWithJson(unsupported, 501))), Effect.flip); + + expect(error).toMatchObject({ code: "unsupported", traceId: "trace-old-server" }); + }), + ); + + it.effect("returns structured library errors without erasing their domain code", () => + Effect.gen(function* () { + const conflict = { + kind: "error", + code: "conflict", + message: "The conversation library changed.", + traceId: "trace-conflict", + }; + const error = yield* fetchEnvironmentConversationLibraryRequest({ + prepared: prepared(), + request: { kind: "import", accountId: "account-1", conversations: [] }, + signer: Option.none(), + }).pipe(Effect.provide(remoteHttpClientLayer(fetchWithJson(conflict, 409))), Effect.flip); + + expect(error).toMatchObject({ code: "conflict", traceId: "trace-conflict" }); + }), + ); +}); diff --git a/packages/client-runtime/src/conversations/http.ts b/packages/client-runtime/src/conversations/http.ts new file mode 100644 index 000000000..85dfe1c55 --- /dev/null +++ b/packages/client-runtime/src/conversations/http.ts @@ -0,0 +1,76 @@ +import { + EnvironmentConversationLibraryErrorSchema, + type EnvironmentConversationLibraryError, +} from "@t3tools/contracts"; +import type { LibraryRequest } from "@t3tools/contracts/conversationLibrary"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { + makeEnvironmentHttpApiUrlBuilder, + type RemoteEnvironmentRequestError, +} from "../rpc/http.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "../state/environmentHttpAuth.ts"; + +const DEFAULT_CONVERSATION_LIBRARY_TIMEOUT_MS = 30_000; +const isConversationLibraryError = Schema.is(EnvironmentConversationLibraryErrorSchema); + +export type ConversationLibraryRequestError = + | RemoteEnvironmentRequestError + | EnvironmentConversationLibraryError; + +export const fetchEnvironmentConversationLibraryRequest = Effect.fn( + "clientRuntime.conversations.fetchEnvironmentConversationLibraryRequest", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly request: LibraryRequest; + readonly signer: Option.Option; + readonly remoteAuthorization?: Option.Option; + readonly timeoutMs?: number; +}) { + const remoteAuthorization = + input.remoteAuthorization ?? (yield* Effect.serviceOption(RemoteEnvironmentAuthorization)); + return yield* executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization, + group: "conversationLibrary", + method: "POST", + url: (httpBaseUrl) => + makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).conversationLibrary.conversationLibrary(), + timeoutMs: input.timeoutMs ?? DEFAULT_CONVERSATION_LIBRARY_TIMEOUT_MS, + request: ({ client, headers }) => { + switch (input.request.kind) { + case "hello": + return client.conversationLibrary({ payload: input.request, headers }); + case "accounts": + return client.conversationLibrary({ payload: input.request, headers }); + case "createAccount": + return client.conversationLibrary({ payload: input.request, headers }); + case "preview": + return client.conversationLibrary({ payload: input.request, headers }); + case "import": + return client.conversationLibrary({ payload: input.request, headers }); + case "list": + return client.conversationLibrary({ payload: input.request, headers }); + case "detail": + return client.conversationLibrary({ payload: input.request, headers }); + case "update": + return client.conversationLibrary({ payload: input.request, headers }); + case "selectSnapshot": + return client.conversationLibrary({ payload: input.request, headers }); + case "remove": + return client.conversationLibrary({ payload: input.request, headers }); + } + }, + }).pipe( + Effect.mapError((error) => + error._tag === "RemoteEnvironmentAuthFetchError" && isConversationLibraryError(error.cause) + ? error.cause + : error, + ), + ); +}); diff --git a/packages/client-runtime/src/conversations/import.test.ts b/packages/client-runtime/src/conversations/import.test.ts new file mode 100644 index 000000000..dc80f0638 --- /dev/null +++ b/packages/client-runtime/src/conversations/import.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { LIBRARY_MAX_REQUEST_BYTES } from "@t3tools/contracts/conversationLibrary"; +import { + ConversationImportFileError, + ConversationLibraryImport, + readConversationExportFile, +} from "./import.ts"; + +const binding = { environmentId: "environment-a", generation: 1 }; + +const conversation = { + id: "conversation-1", + title: "Exported conversation", + mapping: { + node: { + message: { + author: { role: "user" }, + content: { parts: ["Hello"] }, + }, + }, + }, +} as const; + +function file(name = "conversations.json", value: unknown = [conversation], size?: number) { + const text = JSON.stringify(value); + return { + name, + size: size ?? new TextEncoder().encode(text).byteLength, + text: async () => text, + }; +} + +describe("conversation library import", () => { + it("accepts only a valid conversations.json export", async () => { + await expect(readConversationExportFile(file())).resolves.toMatchObject([ + { id: "conversation-1", title: "Exported conversation" }, + ]); + await expect(readConversationExportFile(file("backup.json"))).rejects.toMatchObject({ + name: "ConversationImportFileError", + code: "invalid", + }); + await expect( + readConversationExportFile(file("conversations.json", [{ title: "missing mapping" }])), + ).rejects.toMatchObject({ + name: "ConversationImportFileError", + code: "invalid", + }); + }); + + it("rejects an oversized file before reading it and checks the serialized request size", async () => { + let read = false; + const tooLargeFile = { + name: "conversations.json", + size: LIBRARY_MAX_REQUEST_BYTES + 1, + text: async () => { + read = true; + return "[]"; + }, + }; + await expect(readConversationExportFile(tooLargeFile)).rejects.toBeInstanceOf( + ConversationImportFileError, + ); + expect(read).toBe(false); + + const largeConversation = { + ...conversation, + title: "x".repeat(LIBRARY_MAX_REQUEST_BYTES), + }; + const importer = new ConversationLibraryImport(); + importer.bind(binding, true); + expect(importer.requestPreview("account-1", [largeConversation])).toBeNull(); + expect(importer.getSnapshot()).toMatchObject({ status: "error" }); + expect(importer.getSnapshot().error).toContain("too large"); + }); + + it("requires accepted preview and write capability before a separate import request", () => { + const importer = new ConversationLibraryImport(); + importer.bind(binding, false); + const preview = importer.requestPreview("account-1", [conversation]); + expect(preview?.request.kind).toBe("preview"); + expect( + importer.acceptPreview(preview!, { + kind: "preview", + conversations: [ + { + id: "conversation-1", + title: "Exported conversation", + messageCount: 1, + warningCount: 0, + }, + ], + }), + ).toBe(true); + expect(importer.getSnapshot()).toMatchObject({ status: "ready", accountId: "account-1" }); + expect(importer.requestImport()).toBeNull(); + + importer.bind(binding, true); + const confirmed = importer.requestImport(); + expect(confirmed?.request).toMatchObject({ kind: "import", accountId: "account-1" }); + expect(importer.getSnapshot().status).toBe("importing"); + expect( + importer.acceptImport(confirmed!, { + kind: "imported", + inserted: 1, + duplicates: 0, + older: 0, + conflicts: 0, + revision: 2, + }), + ).toBe(true); + expect(importer.getSnapshot()).toMatchObject({ + status: "complete", + preview: null, + result: { inserted: 1 }, + }); + }); + + it("fences stale preview and import completions across connection generations", () => { + const importer = new ConversationLibraryImport(); + importer.bind(binding, true); + const preview = importer.requestPreview("account-1", [conversation]); + expect(preview).not.toBeNull(); + importer.bind({ ...binding, generation: 2 }, true); + expect(importer.acceptPreview(preview!, { kind: "preview", conversations: [] })).toBe(false); + expect(importer.getSnapshot()).toMatchObject({ status: "idle", binding: { generation: 2 } }); + + const currentPreview = importer.requestPreview("account-1", [conversation]); + expect(currentPreview).not.toBeNull(); + expect(importer.acceptPreview(currentPreview!, { kind: "preview", conversations: [] })).toBe( + true, + ); + const importTicket = importer.requestImport(); + expect(importTicket).not.toBeNull(); + importer.bind({ ...binding, generation: 3 }, true); + expect( + importer.acceptImport(importTicket!, { + kind: "imported", + inserted: 1, + duplicates: 0, + older: 0, + conflicts: 0, + revision: 3, + }), + ).toBe(false); + expect(importer.getSnapshot()).toMatchObject({ status: "idle", binding: { generation: 3 } }); + }); + + it("fences a file read that finishes after the selected environment changes", () => { + const importer = new ConversationLibraryImport(); + importer.bind(binding, true); + const fileRead = importer.beginFileRead("account-1"); + expect(fileRead).not.toBeNull(); + importer.bind({ ...binding, generation: 2 }, true); + expect(importer.requestPreviewForFile(fileRead!, [conversation])).toBeNull(); + expect(importer.getSnapshot()).toMatchObject({ status: "idle", binding: { generation: 2 } }); + }); + + it("cancel releases the prepared payload and invalidates the pending reply", () => { + const importer = new ConversationLibraryImport(); + importer.bind(binding, true); + const ticket = importer.requestPreview("account-1", [conversation]); + expect(ticket).not.toBeNull(); + importer.cancel(); + expect(importer.isPending(ticket!)).toBe(false); + expect(importer.getSnapshot()).toMatchObject({ + status: "idle", + accountId: null, + preview: null, + }); + }); +}); diff --git a/packages/client-runtime/src/conversations/import.ts b/packages/client-runtime/src/conversations/import.ts new file mode 100644 index 000000000..be8c6774e --- /dev/null +++ b/packages/client-runtime/src/conversations/import.ts @@ -0,0 +1,343 @@ +import type { + ExportConversation, + LibraryReply, + LibraryRequest, +} from "@t3tools/contracts/conversationLibrary"; +import { + LIBRARY_MAX_REQUEST_BYTES, + LibraryRequestSchema, +} from "@t3tools/contracts/conversationLibrary"; +import * as Schema from "effect/Schema"; + +import type { LibraryReaderBinding } from "./model.ts"; + +export type ConversationImportFileErrorCode = "invalid" | "too-large"; +const decodeLibraryRequest = Schema.decodeUnknownSync(LibraryRequestSchema); + +export class ConversationImportFileError extends Error { + override readonly name = "ConversationImportFileError"; + readonly code: ConversationImportFileErrorCode; + + constructor(code: ConversationImportFileErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export interface ConversationImportPreviewItem { + readonly id: string; + readonly title: string; + readonly messageCount: number; + readonly warningCount: number; +} + +export interface ConversationImportResult { + readonly inserted: number; + readonly duplicates: number; + readonly older: number; + readonly conflicts: number; + readonly revision: number; +} + +export interface ConversationImportState { + readonly binding: LibraryReaderBinding | null; + readonly canWrite: boolean; + readonly status: "idle" | "previewing" | "ready" | "importing" | "complete" | "error"; + readonly accountId: string | null; + readonly preview: readonly ConversationImportPreviewItem[] | null; + readonly result: ConversationImportResult | null; + readonly error: string | null; +} + +export interface ConversationImportTicket { + readonly binding: LibraryReaderBinding; + readonly request: R; +} + +export interface ConversationImportFileReadTicket { + readonly binding: LibraryReaderBinding; + readonly accountId: string; +} + +type PreviewRequest = Extract; +type ImportRequest = Extract; +type PreviewReply = Extract; +type ImportedReply = Extract; + +const idleState = ( + binding: LibraryReaderBinding | null, + canWrite = false, +): ConversationImportState => ({ + binding, + canWrite, + status: "idle", + accountId: null, + preview: null, + result: null, + error: null, +}); + +function serializedRequestBytes(request: LibraryRequest): number { + return new TextEncoder().encode(JSON.stringify(request)).byteLength; +} + +function checkedRequest(request: R): R { + if (serializedRequestBytes(request) > LIBRARY_MAX_REQUEST_BYTES) { + throw new ConversationImportFileError( + "too-large", + "The selected export is too large to send to this conversation library.", + ); + } + return request; +} + +export async function readConversationExportFile( + file: Pick, +): Promise { + if (file.name !== "conversations.json") { + throw new ConversationImportFileError("invalid", "Choose the conversations.json export file."); + } + if (file.size > LIBRARY_MAX_REQUEST_BYTES) { + throw new ConversationImportFileError( + "too-large", + "The selected export exceeds the 16 MiB limit.", + ); + } + + let value: unknown; + try { + value = JSON.parse(await file.text()); + } catch { + throw new ConversationImportFileError( + "invalid", + "The selected conversations.json file is not valid JSON.", + ); + } + + try { + const parsed = decodeLibraryRequest({ + kind: "preview", + conversations: value, + }); + if (parsed.kind !== "preview") { + throw new ConversationImportFileError( + "invalid", + "The selected export does not contain a conversations list.", + ); + } + checkedRequest(parsed); + return parsed.conversations; + } catch (error) { + if (error instanceof ConversationImportFileError) throw error; + throw new ConversationImportFileError( + "invalid", + "The selected conversations.json file does not match the supported export format.", + ); + } +} + +export class ConversationLibraryImport { + private state: ConversationImportState = idleState(null); + private readonly listeners = new Set<() => void>(); + private pending: ConversationImportTicket | null = null; + private fileRead: ConversationImportFileReadTicket | null = null; + private prepared: ImportRequest | null = null; + + readonly getSnapshot = (): ConversationImportState => this.state; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + private publish(state: ConversationImportState): void { + this.state = state; + for (const listener of this.listeners) listener(); + } + + bind(binding: LibraryReaderBinding | null, canWrite: boolean): void { + const current = this.state.binding; + if ( + current === binding || + (current !== null && + binding !== null && + current.environmentId === binding.environmentId && + current.generation === binding.generation) + ) { + if (this.state.canWrite !== canWrite) this.publish({ ...this.state, canWrite }); + return; + } + this.pending = null; + this.fileRead = null; + this.prepared = null; + this.publish(idleState(binding === null ? null : { ...binding }, canWrite)); + } + + beginFileRead(accountId: string): ConversationImportFileReadTicket | null { + const binding = this.state.binding; + if (binding === null || accountId.trim().length === 0) return null; + const ticket: ConversationImportFileReadTicket = { + binding, + accountId, + }; + this.pending = null; + this.fileRead = ticket; + this.prepared = null; + this.publish({ ...idleState(binding, this.state.canWrite), status: "previewing", accountId }); + return ticket; + } + + requestPreviewForFile( + ticket: ConversationImportFileReadTicket, + conversations: readonly ExportConversation[], + ): ConversationImportTicket | null { + if (this.fileRead !== ticket || !this.sameBinding(ticket.binding, this.state.binding)) + return null; + this.fileRead = null; + return this.requestPreview(ticket.accountId, conversations); + } + + rejectFileRead(ticket: ConversationImportFileReadTicket, message: string): boolean { + if (this.fileRead !== ticket || !this.sameBinding(ticket.binding, this.state.binding)) + return false; + this.fileRead = null; + this.publish({ ...this.state, status: "error", preview: null, error: message }); + return true; + } + + requestPreview( + accountId: string, + conversations: readonly ExportConversation[], + ): ConversationImportTicket | null { + const binding = this.state.binding; + if (binding === null || accountId.trim().length === 0) return null; + this.fileRead = null; + let request: PreviewRequest; + try { + request = checkedRequest({ kind: "preview", conversations }); + } catch (error) { + this.prepared = null; + this.pending = null; + this.publish({ + ...idleState(binding, this.state.canWrite), + status: "error", + error: error instanceof Error ? error.message : "The export is too large.", + }); + return null; + } + this.fileRead = null; + const ticket: ConversationImportTicket = { binding, request }; + this.pending = ticket; + this.prepared = null; + this.publish({ ...idleState(binding, this.state.canWrite), status: "previewing", accountId }); + return ticket; + } + + isPending(ticket: ConversationImportTicket): boolean { + return this.pending === ticket && this.sameBinding(ticket.binding, this.state.binding); + } + + acceptPreview(ticket: ConversationImportTicket, reply: PreviewReply): boolean { + if (!this.isPending(ticket) || ticket.request.kind !== "preview" || reply.kind !== "preview") + return false; + const accountId = this.state.accountId; + if (accountId === null) return false; + let request: ImportRequest; + try { + request = checkedRequest({ + kind: "import", + accountId, + conversations: ticket.request.conversations, + }); + } catch (error) { + this.pending = null; + this.prepared = null; + this.publish({ + ...this.state, + status: "error", + preview: null, + error: error instanceof Error ? error.message : "The export is too large.", + }); + return false; + } + this.pending = null; + this.prepared = request; + this.publish({ ...this.state, status: "ready", preview: reply.conversations, error: null }); + return true; + } + + requestImport(): ConversationImportTicket | null { + const binding = this.state.binding; + const request = this.prepared; + if ( + binding === null || + request === null || + !this.state.canWrite || + this.state.status !== "ready" + ) + return null; + try { + checkedRequest(request); + } catch (error) { + this.prepared = null; + this.publish({ + ...this.state, + status: "error", + preview: null, + error: error instanceof Error ? error.message : "The export is too large.", + }); + return null; + } + const ticket: ConversationImportTicket = { binding, request }; + this.pending = ticket; + this.publish({ ...this.state, status: "importing", error: null }); + return ticket; + } + + acceptImport(ticket: ConversationImportTicket, reply: ImportedReply): boolean { + if (!this.isPending(ticket) || ticket.request.kind !== "import" || reply.kind !== "imported") + return false; + this.pending = null; + this.prepared = null; + this.publish({ + ...this.state, + status: "complete", + preview: null, + result: reply, + error: null, + }); + return true; + } + + reject(ticket: ConversationImportTicket, message: string): boolean { + if (!this.isPending(ticket)) return false; + this.pending = null; + this.prepared = null; + this.publish({ ...this.state, status: "error", preview: null, error: message }); + return true; + } + + cancel(): void { + this.pending = null; + this.fileRead = null; + this.prepared = null; + this.publish( + idleState( + this.state.binding === null ? null : { ...this.state.binding }, + this.state.canWrite, + ), + ); + } + + private sameBinding(a: LibraryReaderBinding | null, b: LibraryReaderBinding | null): boolean { + return ( + a === b || + (a !== null && + b !== null && + a.environmentId === b.environmentId && + a.generation === b.generation) + ); + } +} diff --git a/packages/client-runtime/src/conversations/index.ts b/packages/client-runtime/src/conversations/index.ts new file mode 100644 index 000000000..567587e83 --- /dev/null +++ b/packages/client-runtime/src/conversations/index.ts @@ -0,0 +1,3 @@ +export * from "./model.ts"; +export * from "./http.ts"; +export * from "./import.ts"; diff --git a/packages/client-runtime/src/conversations/model.cases.ts b/packages/client-runtime/src/conversations/model.cases.ts new file mode 100644 index 000000000..0d330b4db --- /dev/null +++ b/packages/client-runtime/src/conversations/model.cases.ts @@ -0,0 +1,686 @@ +import { assert } from "vite-plus/test"; +import type { + LibraryDetail, + LibraryReply, + LibrarySummary, +} from "@t3tools/contracts/conversationLibrary"; +import { ConversationLibraryReader, libraryDetailPage, libraryReaderRowKey } from "./model.ts"; + +const binding = { environmentId: "environment-a", generation: 1 }; +const hello = (revision = 1, canWrite = true): Extract => ({ + kind: "hello", + protocol: "t3.conversation-library.v1", + revision, + canWrite, + capture: "not-enabled", +}); +const row = (patch: Partial = {}): LibrarySummary => ({ + key: "account-a:conversation-1", + accountId: "account-a", + conversationId: "conversation-1", + title: "Supplied export", + snapshotId: "snapshot-1", + sourceUpdatedAt: 1000, + importedAt: 2000, + revision: 1, + unread: true, + pinned: false, + archived: false, + attention: false, + conflicts: false, + messageCount: 1, + warningCount: 1, + ...patch, +}); +const detail = (patch: Partial = {}): LibraryDetail => ({ + kind: "detail", + conversation: row(), + account: { id: "account-a", label: "Personal", workspace: "Personal" }, + snapshotId: "snapshot-1", + snapshotSourceUpdatedAt: 1000, + snapshotImportedAt: 2000, + showHidden: false, + nodeId: "node-1", + messages: [ + { + id: "node-1", + parentId: "missing-parent", + messageId: "message-1", + role: "assistant", + text: "", + createdAt: 1000, + hidden: false, + unsupportedParts: 1, + }, + ], + totalMessages: 1, + readThrough: 1, + offset: 0, + previousOffset: null, + nextOffset: null, + snapshots: [{ id: "snapshot-1", sourceUpdatedAt: 1000, importedAt: 2000, messageCount: 1 }], + snapshotOffset: 0, + snapshotCount: 1, + branches: [{ id: "node-1", preview: "Supplied text" }], + branchOffset: 0, + branchCount: 1, + warnings: ["This branch has a history gap; non-text media is unavailable."], + ...patch, +}); +const page = ( + rows: readonly LibrarySummary[] = [row()], + revision = 1, + cursor: string | null = null, +): Extract => ({ kind: "list", rows, revision, cursor }); + +function present(reader: ConversationLibraryReader, value = detail()): LibraryDetail { + const ticket = reader.requestDetail({ + kind: "detail", + key: value.conversation.key, + snapshotId: value.snapshotId, + }); + assert.ok(ticket); + assert.equal(reader.acceptDetail(ticket, value), true); + return value; +} + +function ready(canWrite = true): ConversationLibraryReader { + const reader = new ConversationLibraryReader(); + reader.bind(binding); + const ticket = reader.requestHello(); + assert.ok(ticket); + assert.equal(reader.acceptHello(ticket, hello(1, canWrite)), true); + return reader; +} + +export const conversationLibraryReaderCases: readonly { + readonly name: string; + readonly run: () => void; +}[] = [ + { + name: "disconnected readers cannot issue requests or grant themselves writes", + run: () => { + const reader = new ConversationLibraryReader(); + assert.equal(reader.requestHello(), null); + assert.equal(reader.requestList(), null); + assert.equal(reader.requestDetail({ kind: "detail", key: "x" }), null); + assert.equal(reader.requestFlags({ pinned: true }), null); + }, + }, + { + name: "connection generation changes clear records and capabilities", + run: () => { + const reader = ready(); + present(reader); + reader.bind({ ...binding, generation: 2 }); + assert.equal(reader.getSnapshot().detail.value, null); + assert.equal(reader.getSnapshot().canWrite, false); + }, + }, + { + name: "environment changes reject old hello capabilities", + run: () => { + const reader = ready(); + const old = reader.requestHello(); + assert.ok(old); + reader.bind({ environmentId: "environment-b", generation: 1 }); + assert.equal(reader.acceptHello(old, hello(10)), false); + assert.equal(reader.getSnapshot().revision, 0); + assert.equal(reader.getSnapshot().canWrite, false); + }, + }, + { + name: "same environment and generation is a stable subscription snapshot", + run: () => { + const reader = ready(); + const before = reader.getSnapshot(); + reader.bind({ ...binding }); + assert.equal(reader.getSnapshot(), before); + }, + }, + { + name: "unsubscribed listeners receive no further notifications", + run: () => { + const reader = ready(); + let calls = 0; + const unsubscribe = reader.subscribe(() => { + calls++; + }); + present(reader); + assert.equal(calls, 2); + unsubscribe(); + reader.bind(null); + assert.equal(calls, 2); + }, + }, + { + name: "account changes clear selection and fence in-flight detail", + run: () => { + const reader = ready(); + const old = reader.requestDetail({ kind: "detail", key: row().key }); + assert.ok(old); + reader.setFilter({ accountId: "account-b", query: "", view: "all" }); + assert.equal(reader.acceptDetail(old, detail()), false); + assert.equal(reader.getSnapshot().detail.value, null); + }, + }, + { + name: "latest selection wins when detail replies arrive out of order", + run: () => { + const reader = ready(); + const old = reader.requestDetail({ kind: "detail", key: "old" }); + assert.ok(old); + const current = present(reader); + assert.equal(reader.acceptDetail(old, detail({ conversation: row({ key: "old" }) })), false); + assert.equal(reader.getSnapshot().detail.value, current); + }, + }, + { + name: "a different account reply is rejected even when the key matches", + run: () => { + const reader = ready(); + reader.setFilter({ accountId: "account-b", query: "", view: "all" }); + const ticket = reader.requestDetail({ kind: "detail", key: row().key }); + assert.ok(ticket); + assert.equal(reader.acceptDetail(ticket, detail()), false); + assert.equal(reader.getSnapshot().detail.status, "error"); + }, + }, + { + name: "summary and account identities must agree", + run: () => { + const reader = ready(); + const ticket = reader.requestDetail({ kind: "detail", key: row().key }); + assert.ok(ticket); + assert.equal( + reader.acceptDetail( + ticket, + detail({ account: { id: "wrong", label: "Personal", workspace: "Personal" } }), + ), + false, + ); + }, + }, + { + name: "explicit snapshots never silently fall back to the default", + run: () => { + const reader = ready(); + const ticket = reader.requestDetail({ kind: "detail", key: row().key, snapshotId: "older" }); + assert.ok(ticket); + assert.equal(reader.acceptDetail(ticket, detail()), false); + }, + }, + { + name: "explicit branches and page windows are verified", + run: () => { + for (const request of [ + { nodeId: "other" }, + { offset: 50 }, + { snapshotOffset: 50 }, + { branchOffset: 50 }, + { showHidden: true }, + ]) { + const reader = ready(); + const ticket = reader.requestDetail({ kind: "detail", key: row().key, ...request }); + assert.ok(ticket); + assert.equal(reader.acceptDetail(ticket, detail()), false); + } + }, + }, + { + name: "search and view changes fence prior list replies", + run: () => { + const reader = ready(); + const old = reader.requestList(); + assert.ok(old); + reader.setFilter({ accountId: null, query: " supplied ", view: "attention" }); + assert.equal(reader.acceptList(old, page()), false); + assert.deepEqual(reader.requestList()?.request, { + kind: "list", + query: "supplied", + view: "attention", + }); + }, + }, + { + name: "account filters use the binding ID rather than display labels", + run: () => { + const reader = ready(); + reader.setFilter({ accountId: "workspace-specific-account-id", query: "", view: "pinned" }); + assert.equal(reader.requestList()?.request.accountId, "workspace-specific-account-id"); + }, + }, + { + name: "same-revision catalog pages append without losing order", + run: () => { + const reader = ready(); + const first = reader.requestList(); + assert.ok(first); + assert.equal(reader.acceptList(first, page([row()], 1, "cursor")), true); + const next = reader.requestList(true); + assert.ok(next); + assert.equal(next.request.cursor, "cursor"); + assert.equal(reader.acceptList(next, page([row({ key: "second" })])), true); + assert.deepEqual( + reader.getSnapshot().list.value?.rows.map((item) => item.key), + [row().key, "second"], + ); + }, + }, + { + name: "catalog revision changes never splice incompatible pages", + run: () => { + const reader = ready(); + const first = reader.requestList(); + assert.ok(first); + reader.acceptList(first, page([row()], 1, "cursor")); + const next = reader.requestList(true); + assert.ok(next); + assert.equal(reader.acceptList(next, page([row({ key: "second" })], 2)), false); + assert.equal(reader.getSnapshot().list.status, "error"); + assert.equal(reader.requestList(true), null); + assert.equal(reader.getSnapshot().list.value?.rows.length, 1); + }, + }, + { + name: "stale first pages cannot overwrite a newer observed revision", + run: () => { + const reader = ready(); + reader.observeRevision(3); + const ticket = reader.requestList(); + assert.ok(ticket); + assert.equal(reader.acceptList(ticket, page([row()], 2)), false); + assert.equal(reader.getSnapshot().revision, 3); + }, + }, + { + name: "duplicate and cross-account catalog rows fail closed", + run: () => { + for (const rows of [[row(), row()], [row({ accountId: "account-b" })]]) { + const reader = ready(); + reader.setFilter({ accountId: "account-a", query: "", view: "all" }); + const ticket = reader.requestList(); + assert.ok(ticket); + assert.equal(reader.acceptList(ticket, page(rows)), false); + } + }, + }, + { + name: "observing a newer revision invalidates pending reads and displayed receipts", + run: () => { + const reader = ready(); + const value = present(reader); + reader.acknowledgeDisplayed(value); + const pending = reader.requestList(); + assert.ok(pending); + reader.observeRevision(2); + assert.equal(reader.acceptList(pending, page()), false); + assert.equal(reader.getSnapshot().detail.status, "stale"); + assert.equal(reader.getSnapshot().displayed, false); + assert.equal(reader.requestMarkRead(), null); + }, + }, + { + name: "network receipt alone never marks a conversation read", + run: () => { + const reader = ready(); + present(reader); + assert.equal(reader.requestMarkRead(), null); + assert.equal(reader.getSnapshot().detail.value?.conversation.unread, true); + }, + }, + { + name: "only the exact displayed current page can produce a read acknowledgement", + run: () => { + const reader = ready(); + const value = present(reader); + assert.equal(reader.acknowledgeDisplayed({ ...value }), false); + assert.equal(reader.acknowledgeDisplayed(value), true); + assert.deepEqual(reader.requestMarkRead()?.request, { + kind: "update", + key: row().key, + readThrough: 1, + }); + }, + }, + { + name: "historical snapshots, alternate branches, gaps without selection, and early pages stay unread", + run: () => { + const variants: readonly Partial[] = [ + { snapshotId: "older", readThrough: 0 }, + { nodeId: "alternate", readThrough: 0 }, + { nodeId: null, messages: [], totalMessages: 0, readThrough: 0 }, + { nextOffset: 50, totalMessages: 100 }, + { nextOffset: null, totalMessages: 100 }, + { conversation: row({ conflicts: true }) }, + { conversation: row({ unread: false }) }, + { readThrough: 2 }, + ]; + for (const variant of variants) { + const reader = ready(); + const value = present(reader, detail(variant)); + reader.acknowledgeDisplayed(value); + assert.equal(reader.requestMarkRead(), null); + } + }, + }, + { + name: "read-only clients cannot alter flags, select defaults, remove, or acknowledge reads", + run: () => { + const reader = ready(false); + const value = present(reader); + reader.acknowledgeDisplayed(value); + assert.equal(reader.requestFlags({ pinned: true }), null); + assert.equal(reader.requestSelectSnapshot("older"), null); + assert.equal(reader.requestRemoveLocalCopy(), null); + assert.equal(reader.requestMarkRead(), null); + }, + }, + { + name: "flag changes carry only explicit flag fields and never smuggle a read marker", + run: () => { + const reader = ready(); + present(reader); + const flags = { pinned: true, readThrough: 999, key: "different" }; + assert.deepEqual(reader.requestFlags(flags)?.request, { + kind: "update", + key: row().key, + pinned: true, + }); + assert.equal(reader.getSnapshot().detail.value?.conversation.pinned, false); + }, + }, + { + name: "writes are single-flight and never applied optimistically", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestFlags({ attention: true }); + assert.ok(ticket); + assert.equal(reader.requestFlags({ archived: true }), null); + assert.equal(reader.getSnapshot().detail.value?.conversation.attention, false); + assert.equal(reader.acceptMutation(ticket, { kind: "updated", revision: 2 }), true); + assert.equal(reader.getSnapshot().mutationPending, false); + assert.equal(reader.getSnapshot().detail.status, "stale"); + assert.equal(reader.requestFlags({ archived: true }), null); + }, + }, + { + name: "snapshot selection and local removal carry the observed content revision", + run: () => { + const select = ready(); + present(select); + assert.deepEqual(select.requestSelectSnapshot("older")?.request, { + kind: "selectSnapshot", + key: row().key, + snapshotId: "older", + expectedRevision: 1, + }); + const remove = ready(); + present(remove); + assert.deepEqual(remove.requestRemoveLocalCopy()?.request, { + kind: "remove", + key: row().key, + expectedRevision: 1, + }); + }, + }, + { + name: "local removal clears only the record whose removal was acknowledged", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestRemoveLocalCopy(); + assert.ok(ticket); + const other = present(reader, detail({ conversation: row({ key: "other" }) })); + assert.equal(reader.acceptMutation(ticket, { kind: "removed", revision: 2 }), true); + assert.equal(reader.getSnapshot().detail.value, other); + assert.equal(reader.getSnapshot().detail.status, "stale"); + }, + }, + { + name: "removing the selected local copy clears its displayed state", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestRemoveLocalCopy(); + assert.ok(ticket); + reader.acceptMutation(ticket, { kind: "removed", revision: 2 }); + assert.equal(reader.getSnapshot().detail.value, null); + }, + }, + { + name: "a mutation from the old connection cannot alter the new connection", + run: () => { + const reader = ready(); + present(reader); + const old = reader.requestFlags({ pinned: true }); + assert.ok(old); + reader.bind({ ...binding, generation: 2 }); + assert.equal(reader.acceptMutation(old, { kind: "updated", revision: 5 }), false); + assert.equal(reader.getSnapshot().revision, 0); + }, + }, + { + name: "wrong mutation reply kinds invalidate the view instead of inventing success", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestRemoveLocalCopy(); + assert.ok(ticket); + assert.equal(reader.acceptMutation(ticket, { kind: "updated", revision: 2 }), false); + assert.equal(reader.getSnapshot().detail.status, "stale"); + assert.match(reader.getSnapshot().error ?? "", /reconcile/); + }, + }, + { + name: "unknown write outcomes require refresh and are never retried by the model", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestFlags({ pinned: true }); + assert.ok(ticket); + assert.equal(reader.reject(ticket, "Connection lost; outcome unknown."), true); + assert.equal(reader.getSnapshot().mutationPending, false); + assert.equal(reader.getSnapshot().detail.status, "stale"); + assert.equal(reader.requestFlags({ pinned: true }), null); + }, + }, + { + name: "a failed obsolete request cannot replace the current error or page", + run: () => { + const reader = ready(); + const old = reader.requestDetail({ kind: "detail", key: "old" }); + assert.ok(old); + const value = present(reader); + assert.equal(reader.reject(old, "obsolete failure"), false); + assert.equal(reader.getSnapshot().detail.value, value); + }, + }, + { + name: "capability refreshes can revoke write affordances without deleting reading data", + run: () => { + const reader = ready(); + const value = present(reader); + const ticket = reader.requestHello(); + assert.ok(ticket); + reader.acceptHello(ticket, hello(1, false)); + assert.equal(reader.getSnapshot().detail.value, value); + assert.equal(reader.requestFlags({ pinned: true }), null); + }, + }, + { + name: "history gaps, unsupported media markers, roles, and branch order remain untouched", + run: () => { + const reader = ready(); + const value = present(reader); + assert.equal(reader.getSnapshot().detail.value, value); + assert.equal(reader.getSnapshot().detail.value?.messages[0]?.unsupportedParts, 1); + assert.equal(reader.getSnapshot().detail.value?.warnings, value.warnings); + assert.equal( + reader.getSnapshot().detail.value?.messages[0]?.text, + "", + ); + }, + }, + { + name: "message, snapshot, and branch paging remain anchored to the visible snapshot", + run: () => { + const value = detail(); + assert.deepEqual(libraryDetailPage(value, { offset: 50, branchOffset: 50 }), { + kind: "detail", + key: row().key, + snapshotId: "snapshot-1", + nodeId: "node-1", + offset: 50, + snapshotOffset: 0, + branchOffset: 50, + showHidden: false, + }); + const wider = { offset: 50, key: "wrong", snapshotId: "wrong" }; + assert.equal(libraryDetailPage(value, wider).key, row().key); + assert.equal(libraryDetailPage(value, wider).snapshotId, "snapshot-1"); + }, + }, + { + name: "render keys distinguish account/workspace bindings and connection generations", + run: () => { + const a = libraryReaderRowKey(binding, row()); + assert.notEqual( + a, + libraryReaderRowKey(binding, row({ accountId: "same-label-other-workspace" })), + ); + assert.notEqual(a, libraryReaderRowKey({ ...binding, generation: 2 }, row())); + assert.notEqual( + a, + libraryReaderRowKey({ ...binding, environmentId: "environment-b" }, row()), + ); + }, + }, + { + name: "capability replies older than observed data cannot restore write affordances", + run: () => { + const reader = ready(); + reader.observeRevision(2); + const ticket = reader.requestHello(); + assert.ok(ticket); + assert.equal(reader.acceptHello(ticket, hello(1, true)), false); + assert.equal(reader.getSnapshot().canWrite, false); + }, + }, + { + name: "late successful mutation acknowledgements never regress a newer observed revision", + run: () => { + const reader = ready(); + present(reader); + const ticket = reader.requestFlags({ pinned: true }); + assert.ok(ticket); + reader.observeRevision(5); + assert.equal(reader.acceptMutation(ticket, { kind: "updated", revision: 2 }), true); + assert.equal(reader.getSnapshot().revision, 5); + assert.equal(reader.getSnapshot().mutationPending, false); + assert.equal(reader.getSnapshot().error, null); + }, + }, + { + name: "clearing selection fences its pending detail without changing the account filter", + run: () => { + const reader = ready(); + reader.setFilter({ accountId: "account-a", query: "", view: "all" }); + const ticket = reader.requestDetail({ kind: "detail", key: row().key }); + assert.ok(ticket); + reader.clearSelection(); + assert.equal(reader.acceptDetail(ticket, detail()), false); + assert.equal(reader.getSnapshot().selection, null); + assert.equal(reader.getSnapshot().filter.accountId, "account-a"); + }, + }, + { + name: "a failed detail keeps an explicit retry location instead of reusing another selection", + run: () => { + const reader = ready(); + const ticket = reader.requestDetail({ kind: "detail", key: row().key, snapshotId: "older" }); + assert.ok(ticket); + reader.reject(ticket, "Temporary failure"); + assert.deepEqual(reader.getSnapshot().selection, ticket.request); + assert.equal(reader.getSnapshot().detail.value, null); + }, + }, + { + name: "successful detail receipts anchor future refreshes to the displayed snapshot and branch", + run: () => { + const reader = ready(); + const value = present(reader); + assert.deepEqual(reader.getSnapshot().selection, libraryDetailPage(value, {})); + }, + }, + { + name: "tickets cannot be replayed or replaced by a lookalike object", + run: () => { + const reader = ready(); + const ticket = reader.requestList(); + assert.ok(ticket); + assert.equal(reader.acceptList({ ...ticket }, page()), false); + assert.equal(reader.acceptList(ticket, page()), true); + assert.equal(reader.acceptList(ticket, page([], 2)), false); + assert.equal(reader.getSnapshot().list.value?.rows.length, 1); + }, + }, + { + name: "reply acceptance is atomic when a subscriber changes the active environment", + run: () => { + for (const lane of ["hello", "list", "detail"] as const) { + const reader = ready(); + reader.subscribe(() => { + const state = reader.getSnapshot(); + if (state.binding?.environmentId === binding.environmentId && state.revision === 2) { + reader.bind({ environmentId: "environment-b", generation: 1 }); + } + }); + if (lane === "hello") { + const ticket = reader.requestHello(); + assert.ok(ticket); + reader.acceptHello(ticket, hello(2)); + } else if (lane === "list") { + const ticket = reader.requestList(); + assert.ok(ticket); + reader.acceptList(ticket, page([row()], 2)); + } else { + present(reader, detail({ conversation: row({ revision: 2 }), readThrough: 2 })); + } + assert.equal(reader.getSnapshot().binding?.environmentId, "environment-b"); + assert.equal(reader.getSnapshot().revision, 0); + assert.equal(reader.getSnapshot().canWrite, false); + assert.equal(reader.getSnapshot().list.value, null); + assert.equal(reader.getSnapshot().detail.value, null); + } + }, + }, + { + name: "a rebind during a request notification prevents dispatch of the obsolete ticket", + run: () => { + const reader = ready(); + reader.subscribe(() => { + if (reader.getSnapshot().list.status === "loading") reader.bind(null); + }); + const ticket = reader.requestList(); + assert.ok(ticket); + assert.equal(reader.isPending(ticket), false); + }, + }, + { + name: "only the currently pending exact ticket is dispatchable", + run: () => { + const reader = ready(); + const old = reader.requestList(); + assert.ok(old); + assert.equal(reader.isPending(old), true); + const next = reader.requestList(); + assert.ok(next); + assert.equal(reader.isPending(old), false); + assert.equal(reader.isPending(next), true); + reader.acceptList(next, page()); + assert.equal(reader.isPending(next), false); + }, + }, +]; diff --git a/packages/client-runtime/src/conversations/model.test.ts b/packages/client-runtime/src/conversations/model.test.ts new file mode 100644 index 000000000..468731940 --- /dev/null +++ b/packages/client-runtime/src/conversations/model.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "vite-plus/test"; +import { conversationLibraryReaderCases } from "./model.cases.ts"; + +describe("conversation library reader", () => { + it.each(conversationLibraryReaderCases)("$name", ({ run }) => run()); +}); diff --git a/packages/client-runtime/src/conversations/model.ts b/packages/client-runtime/src/conversations/model.ts new file mode 100644 index 000000000..2340ef6a8 --- /dev/null +++ b/packages/client-runtime/src/conversations/model.ts @@ -0,0 +1,491 @@ +import type { + LibraryAccount, + LibraryDetail, + LibraryReply, + LibraryRequest, + LibrarySummary, + LibraryView, +} from "@t3tools/contracts/conversationLibrary"; + +export interface LibraryReaderBinding { + readonly environmentId: string; + readonly generation: number; +} + +export interface LibraryReaderFilter { + readonly accountId: string | null; + readonly query: string; + readonly view: LibraryView; +} + +type ListReply = Extract; +type HelloReply = Extract; +type DetailRequest = Extract; +type UpdateRequest = Extract; +type MutationRequest = Extract< + LibraryRequest, + { readonly kind: "update" | "selectSnapshot" | "remove" } +>; +type ReadRequest = Extract< + LibraryRequest, + { readonly kind: "hello" | "accounts" | "list" | "detail" } +>; +type Lane = ReadRequest["kind"] | "mutation"; + +export interface LibraryReaderPage { + readonly status: "idle" | "loading" | "ready" | "stale" | "error"; + readonly value: A | null; + readonly error: string | null; +} + +export interface LibraryReaderState { + readonly binding: LibraryReaderBinding | null; + readonly filter: LibraryReaderFilter; + readonly canWrite: boolean; + readonly revision: number; + readonly accounts: LibraryReaderPage; + readonly list: LibraryReaderPage; + readonly detail: LibraryReaderPage; + readonly selection: DetailRequest | null; + readonly displayed: boolean; + readonly mutationPending: boolean; + readonly error: string | null; +} + +export interface LibraryReaderTicket { + readonly binding: LibraryReaderBinding; + readonly request: R; +} + +const idle = (): LibraryReaderPage => ({ status: "idle", value: null, error: null }); +const initialFilter: LibraryReaderFilter = { accountId: null, query: "", view: "all" }; +const initialState = (binding: LibraryReaderBinding | null): LibraryReaderState => ({ + binding, + filter: initialFilter, + canWrite: false, + revision: 0, + accounts: idle(), + list: idle(), + detail: idle(), + selection: null, + displayed: false, + mutationPending: false, + error: null, +}); + +function stale(page: LibraryReaderPage): LibraryReaderPage { + return { status: page.value === null ? "idle" : "stale", value: page.value, error: null }; +} + +function sameBinding(a: LibraryReaderBinding | null, b: LibraryReaderBinding | null): boolean { + return ( + a === b || + (a !== null && + b !== null && + a.environmentId === b.environmentId && + a.generation === b.generation) + ); +} + +function detailMatches( + request: DetailRequest, + detail: LibraryDetail, + accountId: string | null, +): boolean { + return ( + detail.conversation.key === request.key && + detail.account.id === detail.conversation.accountId && + (accountId === null || detail.account.id === accountId) && + (request.snapshotId === undefined || request.snapshotId === detail.snapshotId) && + (request.nodeId === undefined || request.nodeId === detail.nodeId) && + (request.offset === undefined || request.offset === detail.offset) && + (request.snapshotOffset ?? 0) === detail.snapshotOffset && + (request.branchOffset ?? 0) === detail.branchOffset && + (request.showHidden ?? false) === detail.showHidden + ); +} + +/** + * Client-only presentation state. Transport adapters must decode replies first and + * rebind whenever T3's environment connection generation changes. Tickets identify + * pending work; they are not authorization and never execute a request themselves. + */ +export class ConversationLibraryReader { + private state: LibraryReaderState = initialState(null); + private readonly listeners = new Set<() => void>(); + private readonly pending = new Map(); + private appendList = false; + + readonly getSnapshot = (): LibraryReaderState => this.state; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + private publish(state: LibraryReaderState): void { + this.state = state; + for (const listener of this.listeners) listener(); + } + + bind(binding: LibraryReaderBinding | null): void { + if (sameBinding(this.state.binding, binding)) return; + this.pending.clear(); + this.appendList = false; + this.publish(initialState(binding === null ? null : { ...binding })); + } + + setFilter(filter: LibraryReaderFilter): void { + const next = { ...filter, query: filter.query.trim() }; + const current = this.state.filter; + if ( + current.accountId === next.accountId && + current.query === next.query && + current.view === next.view + ) + return; + this.pending.delete("list"); + this.pending.delete("detail"); + this.appendList = false; + this.publish({ + ...this.state, + filter: next, + list: idle(), + detail: idle(), + selection: null, + displayed: false, + error: null, + }); + } + + clearSelection(): void { + this.pending.delete("detail"); + this.publish({ ...this.state, detail: idle(), selection: null, displayed: false }); + } + + private begin(lane: Lane, request: R): LibraryReaderTicket | null { + const binding = this.state.binding; + if (binding === null) return null; + const ticket = { binding, request }; + this.pending.set(lane, ticket); + return ticket; + } + + private owns(lane: Lane, ticket: LibraryReaderTicket): boolean { + return this.pending.get(lane) === ticket && sameBinding(ticket.binding, this.state.binding); + } + + /** Check immediately before dispatching through the matching T3 connection. */ + isPending(ticket: LibraryReaderTicket): boolean { + return [...this.pending.keys()].some((lane) => this.owns(lane, ticket)); + } + + requestHello(): LibraryReaderTicket> | null { + return this.begin("hello", { kind: "hello" }); + } + + acceptHello(ticket: LibraryReaderTicket, reply: HelloReply): boolean { + if (!this.owns("hello", ticket)) return false; + this.pending.delete("hello"); + if (reply.revision < this.state.revision) { + this.publish({ + ...this.state, + canWrite: false, + error: "The library capability reply is stale. Refresh the connection.", + }); + return false; + } + const state = this.advanceRevision(reply.revision); + this.publish({ ...state, canWrite: reply.canWrite, error: null }); + return true; + } + + requestAccounts(): LibraryReaderTicket> | null { + const ticket = this.begin("accounts", { kind: "accounts" }); + if (ticket) + this.publish({ + ...this.state, + accounts: { ...this.state.accounts, status: "loading", error: null }, + }); + return ticket; + } + + acceptAccounts(ticket: LibraryReaderTicket, accounts: readonly LibraryAccount[]): boolean { + if (!this.owns("accounts", ticket)) return false; + this.pending.delete("accounts"); + this.publish({ ...this.state, accounts: { status: "ready", value: accounts, error: null } }); + return true; + } + + requestList( + append = false, + ): LibraryReaderTicket> | null { + const list = this.state.list; + if (append && (list.status !== "ready" || list.value?.cursor == null)) return null; + const { accountId, query, view } = this.state.filter; + const ticket = this.begin("list", { + kind: "list", + ...(accountId === null ? {} : { accountId }), + query, + view, + ...(append && list.value?.cursor ? { cursor: list.value.cursor } : {}), + }); + if (ticket) { + this.appendList = append; + this.publish({ ...this.state, list: { ...list, status: "loading", error: null } }); + } + return ticket; + } + + acceptList(ticket: LibraryReaderTicket, reply: ListReply): boolean { + if (!this.owns("list", ticket)) return false; + const prior = this.state.list.value; + if ( + reply.revision < this.state.revision || + (this.appendList && prior?.revision !== reply.revision) + ) { + this.reject(ticket, "The library changed. Refresh before loading another page."); + return false; + } + const rows = this.appendList && prior ? [...prior.rows, ...reply.rows] : reply.rows; + if ( + new Set(rows.map((row) => row.key)).size !== rows.length || + rows.some( + (row) => + this.state.filter.accountId !== null && row.accountId !== this.state.filter.accountId, + ) + ) { + this.reject(ticket, "The library returned an inconsistent conversation page."); + return false; + } + this.pending.delete("list"); + const state = this.advanceRevision(reply.revision); + this.publish({ ...state, list: { status: "ready", value: { ...reply, rows }, error: null } }); + return true; + } + + requestDetail(request: DetailRequest): LibraryReaderTicket | null { + const ticket = this.begin("detail", { ...request }); + if (ticket) + this.publish({ + ...this.state, + detail: { status: "loading", value: null, error: null }, + selection: ticket.request, + displayed: false, + }); + return ticket; + } + + acceptDetail(ticket: LibraryReaderTicket, reply: LibraryDetail): boolean { + if (!this.owns("detail", ticket) || ticket.request.kind !== "detail") return false; + if (!detailMatches(ticket.request, reply, this.state.filter.accountId)) { + this.reject( + ticket, + "The library returned a different conversation, snapshot, branch, or page.", + ); + return false; + } + this.pending.delete("detail"); + const state = this.advanceRevision(reply.conversation.revision); + this.publish({ + ...state, + detail: { status: "ready", value: reply, error: null }, + selection: libraryDetailPage(reply, {}), + displayed: false, + }); + return true; + } + + /** Call after rendering this exact page, not on network receipt or prefetch. */ + acknowledgeDisplayed(detail: LibraryDetail): boolean { + if (this.state.detail.status !== "ready" || this.state.detail.value !== detail) return false; + if (!this.state.displayed) this.publish({ ...this.state, displayed: true }); + return true; + } + + private advanceRevision(revision: number): LibraryReaderState { + if (revision <= this.state.revision) return this.state; + this.pending.delete("list"); + this.pending.delete("detail"); + return { + ...this.state, + revision, + list: stale(this.state.list), + detail: stale(this.state.detail), + displayed: false, + }; + } + + observeRevision(revision: number): void { + const state = this.advanceRevision(revision); + if (state !== this.state) this.publish(state); + } + + private mutate(request: MutationRequest): LibraryReaderTicket | null { + const detail = this.state.detail; + if ( + !this.state.canWrite || + this.state.mutationPending || + detail.status !== "ready" || + detail.value?.conversation.key !== request.key + ) + return null; + const ticket = this.begin("mutation", request); + if (ticket) this.publish({ ...this.state, mutationPending: true, error: null }); + return ticket; + } + + requestFlags( + flags: Pick, + ): LibraryReaderTicket | null { + const conversation = this.state.detail.value?.conversation; + if ( + !conversation || + (flags.pinned === undefined && flags.archived === undefined && flags.attention === undefined) + ) + return null; + return this.mutate({ + kind: "update", + key: conversation.key, + ...(flags.pinned === undefined ? {} : { pinned: flags.pinned }), + ...(flags.archived === undefined ? {} : { archived: flags.archived }), + ...(flags.attention === undefined ? {} : { attention: flags.attention }), + }); + } + + requestMarkRead(): LibraryReaderTicket | null { + const detail = this.state.detail.value; + if ( + !detail || + !this.state.displayed || + !detail.conversation.unread || + detail.conversation.conflicts || + detail.snapshotId !== detail.conversation.snapshotId || + detail.nodeId === null || + detail.messages.length === 0 || + detail.nextOffset !== null || + detail.offset + detail.messages.length !== detail.totalMessages || + detail.readThrough <= 0 || + detail.readThrough !== detail.conversation.revision + ) + return null; + return this.mutate({ + kind: "update", + key: detail.conversation.key, + readThrough: detail.readThrough, + }); + } + + requestSelectSnapshot(snapshotId: string): LibraryReaderTicket | null { + const conversation = this.state.detail.value?.conversation; + return conversation + ? this.mutate({ + kind: "selectSnapshot", + key: conversation.key, + snapshotId, + expectedRevision: conversation.revision, + }) + : null; + } + + requestRemoveLocalCopy(): LibraryReaderTicket | null { + const conversation = this.state.detail.value?.conversation; + return conversation + ? this.mutate({ + kind: "remove", + key: conversation.key, + expectedRevision: conversation.revision, + }) + : null; + } + + acceptMutation( + ticket: LibraryReaderTicket, + reply: Extract, + ): boolean { + if (!this.owns("mutation", ticket)) return false; + const expected = ticket.request.kind === "remove" ? "removed" : "updated"; + if (reply.kind !== expected) { + this.reject( + ticket, + "The library mutation response is inconsistent. Refresh to reconcile its outcome.", + ); + return false; + } + this.pending.delete("mutation"); + this.pending.delete("list"); + this.pending.delete("detail"); + const removedSelected = + ticket.request.kind === "remove" && this.state.selection?.key === ticket.request.key; + this.publish({ + ...this.state, + revision: Math.max(this.state.revision, reply.revision), + mutationPending: false, + list: stale(this.state.list), + detail: removedSelected ? idle() : stale(this.state.detail), + selection: removedSelected ? null : this.state.selection, + displayed: false, + }); + return true; + } + + /** A failed write is not retried here: its outcome may be unknown to the client. */ + reject(ticket: LibraryReaderTicket, message: string): boolean { + const lane = [...this.pending].find(([, pending]) => pending === ticket)?.[0]; + if (lane === undefined || !this.owns(lane, ticket)) return false; + this.pending.delete(lane); + if (lane === "hello") this.publish({ ...this.state, canWrite: false, error: message }); + else if (lane === "mutation") { + this.pending.delete("list"); + this.pending.delete("detail"); + this.publish({ + ...this.state, + mutationPending: false, + list: stale(this.state.list), + detail: stale(this.state.detail), + displayed: false, + error: message, + }); + } else if (lane === "accounts") { + this.publish({ + ...this.state, + accounts: { ...this.state.accounts, status: "error", error: message }, + }); + } else if (lane === "list") { + this.publish({ + ...this.state, + list: { ...this.state.list, status: "error", error: message }, + }); + } else + this.publish({ + ...this.state, + detail: { status: "error", value: null, error: message }, + displayed: false, + }); + return true; + } +} + +/** Keep every page tied to the snapshot and branch actually being read. */ +export function libraryDetailPage( + detail: LibraryDetail, + page: Partial>, +): DetailRequest { + return { + kind: "detail", + key: detail.conversation.key, + snapshotId: detail.snapshotId, + ...(detail.nodeId === null ? {} : { nodeId: detail.nodeId }), + offset: page.offset ?? detail.offset, + snapshotOffset: page.snapshotOffset ?? detail.snapshotOffset, + branchOffset: page.branchOffset ?? detail.branchOffset, + showHidden: page.showHidden ?? detail.showHidden, + }; +} + +/** Conversation IDs are source-local; never use one alone as a rendered row key. */ +export function libraryReaderRowKey(binding: LibraryReaderBinding, row: LibrarySummary): string { + return JSON.stringify([binding.environmentId, binding.generation, row.accountId, row.key]); +} diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index a9833f749..ea3a5818e 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -5,24 +5,34 @@ import { CommandId, EnvironmentId, MessageId, + GitCommandError, + OrchestrationDispatchCommandError, + type VcsListRefsInput, + type VcsListRefsResult, NodeId, ORCHESTRATION_V2_WS_METHODS, PlanId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + ProviderSessionId, RunId, RuntimeRequestId, ThreadId, TurnItemId, WS_METHODS, type OrchestrationV2Command, + type OrchestrationV2ProviderSession, type OrchestrationV2ThreadLaunchInput, type OrchestrationV2ThreadProjection, type ProjectMutation, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -34,6 +44,7 @@ import { } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; +import type { EnvironmentRpcFailure } from "../rpc/client.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { v2Now, v2Projection, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts"; import { @@ -51,6 +62,7 @@ import { revertThreadCheckpoint, settleThread, startThreadTurn, + stopThreadSession, unsettleThread, updateProject, updateThreadMetadata, @@ -72,14 +84,20 @@ const TARGET = new PrimaryConnectionTarget({ }); const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (input: { + readonly listRefs?: ( + input: VcsListRefsInput, + ) => Effect.Effect>; + readonly onLaunch?: () => Effect.Effect; readonly commands: OrchestrationV2Command[]; readonly projects: ProjectMutation[]; readonly launches?: OrchestrationV2ThreadLaunchInput[]; readonly projection?: OrchestrationV2ThreadProjection; readonly projectionRequests?: ThreadId[]; readonly advertiseServerResolvedCommandContext?: boolean; + readonly advertiseQueuedToolBoundaryDelivery?: boolean; }) { const client = { + [WS_METHODS.vcsListRefs]: input.listRefs ?? (() => Effect.never), [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => Effect.sync(() => { input.commands.push(command); @@ -93,8 +111,9 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct return input.projection ?? v2Projection; }), [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) => - Effect.sync(() => { + Effect.gen(function* () { input.launches?.push(launchInput); + yield* input.onLaunch?.() ?? Effect.void; return { threadId: launchInput.threadId ?? v2ThreadId, projection: input.projection ?? v2Projection, @@ -125,6 +144,9 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct environment: { capabilities: { repositoryIdentity: true, + ...(input.advertiseQueuedToolBoundaryDelivery === undefined + ? {} + : { queuedToolBoundaryDelivery: input.advertiseQueuedToolBoundaryDelivery }), ...(input.advertiseServerResolvedCommandContext === false ? {} : { serverResolvedCommandContext: true }), @@ -833,6 +855,163 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect( + "stops all projected sessions with detach only, preserving thread lifecycle state", + () => + Effect.gen(function* () { + const projection: OrchestrationV2ThreadProjection = { + ...v2Projection, + thread: { ...v2Projection.thread, settledOverride: "active" }, + providerSessions: (["ready", "running"] as const).map( + (status, index): OrchestrationV2ProviderSession => ({ + id: ProviderSessionId.make(`session-${index}`), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status, + cwd: "/workspace/project", + model: null, + capabilities: { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: false, + supportsProviderSwitchingViaHandoff: false, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: false, + canReadThreadSnapshot: false, + canRollbackThread: false, + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: false, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: false, + emitsTurnCompleted: false, + supportsInterrupt: false, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: false, + terminalStatusQuality: "none", + }, + streaming: { + streamsAssistantText: false, + streamsReasoning: false, + streamsToolOutput: false, + streamsPlanText: false, + emitsMessageCompleted: false, + }, + tools: { + exposesToolItemIds: false, + emitsToolStarted: false, + emitsToolCompleted: false, + emitsToolOutput: false, + supportsMcpTools: false, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: false, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: false, + approvalCallbacksAreLiveOnly: false, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: false, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: false, + canGenerateSummaries: false, + canConsumeHandoffSummaries: false, + supportsDeltaHandoff: false, + supportsFullThreadHandoff: false, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: false, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: false, + }, + identity: { + nativeThreadIds: "none", + nativeTurnIds: "none", + nativeItemIds: "none", + nativeRequestIds: "none", + }, + runtimePolicy: { + enforcement: "client-boundary", + }, + }, + createdAt: v2Now, + updatedAt: v2Now, + lastError: null, + }), + ), + }; + const commands: OrchestrationV2Command[] = []; + const projectionRequests: ThreadId[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + projection, + projectionRequests, + }); + const result = yield* stopThreadSession({ + threadId: v2ThreadId, + commandId: CommandId.make("stop-command"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(projectionRequests).toEqual([v2ThreadId]); + expect(commands).toEqual( + projection.providerSessions.map((session) => ({ + type: "provider-session.detach", + threadId: v2ThreadId, + commandId: `stop-command:detach:${session.id}`, + providerSessionId: session.id, + reason: "client-requested", + })), + ); + expect(result).toEqual({ sequence: 2 }); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("does not dispatch any lifecycle command when no provider sessions remain", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + projection: v2Projection, + }); + const result = yield* stopThreadSession({ + threadId: v2ThreadId, + commandId: CommandId.make("stop-empty"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(commands).toEqual([]); + expect(result).toEqual({ sequence: 0 }); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("dispatches settle and unsettle commands without timestamps", () => Effect.gen(function* () { const dispatched: OrchestrationV2Command[] = []; @@ -970,3 +1149,362 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); }); + +it.effect.each([true, false, undefined] as const)( + "negotiates queued tool eligibility only with capability %s", + (capability) => + Effect.gen(function* () { + for (const serverResolution of [true, false]) { + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + advertiseServerResolvedCommandContext: serverResolution, + ...(capability === undefined ? {} : { advertiseQueuedToolBoundaryDelivery: capability }), + }); + for (const mode of ["queue", "auto", "start", "steer", "restart"] as const) { + yield* startThreadTurn({ + commandId: CommandId.make(`policy-${mode}`), + threadId: v2ThreadId, + message: { + messageId: MessageId.make(`message-${mode}`), + role: "user", + text: "Owner follow-up", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + dispatchMode: mode, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + const command = commands.at(-1)!; + if (capability === true && (mode === "queue" || mode === "auto")) + expect(command).toHaveProperty("queuedToolBoundaryEligible", true); + else expect(command).not.toHaveProperty("queuedToolBoundaryEligible"); + } + for (const source of ["mcp", "provider", "server"] as const) { + yield* startThreadTurn({ + commandId: CommandId.make(`policy-source-${source}`), + threadId: v2ThreadId, + creationSource: source, + message: { + messageId: MessageId.make(`source-${source}`), + role: "user", + text: "Special delivery", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + dispatchMode: "queue", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(commands.at(-1)).not.toHaveProperty("queuedToolBoundaryEligible"); + } + } + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), +); + +const automaticWorktreeTurn = { + commandId: CommandId.make("captured-command"), + threadId: ThreadId.make("captured-thread"), + message: { + messageId: MessageId.make("captured-message"), + role: "user" as const, + text: "Start the task", + attachments: [], + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + bootstrap: { + createThread: { + projectId: ProjectId.make("captured-project"), + title: "Captured draft", + modelSelection: v2Projection.thread.modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(v2Now), + }, + prepareWorktree: { + projectCwd: "/workspace/project", + branch: "t3/captured-branch", + startFromOrigin: true, + }, + runSetupScript: true, + }, +}; + +const automaticWorktreeLaunch = { + commandId: automaticWorktreeTurn.commandId, + creationSource: "web" as const, + threadId: automaticWorktreeTurn.threadId, + projectId: automaticWorktreeTurn.bootstrap.createThread.projectId, + title: automaticWorktreeTurn.bootstrap.createThread.title, + generateTitle: false, + modelSelection: automaticWorktreeTurn.bootstrap.createThread.modelSelection, + runtimeMode: automaticWorktreeTurn.runtimeMode, + interactionMode: automaticWorktreeTurn.interactionMode, + workspaceStrategy: { + type: "worktree" as const, + branch: automaticWorktreeTurn.bootstrap.prepareWorktree.branch, + startFromOrigin: true, + }, + initialMessage: { + messageId: automaticWorktreeTurn.message.messageId, + text: automaticWorktreeTurn.message.text, + attachments: [], + }, +}; + +const refsResult = (refs: VcsListRefsResult["refs"], isRepo = true): VcsListRefsResult => ({ + refs, + isRepo, + hasPrimaryRemote: refs.some((ref) => ref.isRemote === true), + nextCursor: null, + totalCount: refs.length, +}); + +const withBaseRef = (baseRef: string) => ({ + ...automaticWorktreeLaunch, + workspaceStrategy: { ...automaticWorktreeLaunch.workspaceStrategy, baseRef }, +}); + +describe("v2 worktree start compatibility", () => { + it.effect("waits for one unfiltered default-ref lookup before launching on older servers", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const lookups: VcsListRefsInput[] = []; + const firstOperation = yield* Deferred.make<"refs" | "launch">(); + const refs = yield* Deferred.make(); + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + onLaunch: () => Deferred.succeed(firstOperation, "launch").pipe(Effect.asVoid), + listRefs: (input) => + Effect.gen(function* () { + lookups.push(input); + yield* Deferred.succeed(firstOperation, "refs"); + return yield* Deferred.await(refs); + }), + }); + const start = yield* startThreadTurn(automaticWorktreeTurn).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + expect(yield* Deferred.await(firstOperation)).toBe("refs"); + expect(lookups).toEqual([{ cwd: "/workspace/project", limit: 100 }]); + expect(launches).toEqual([]); + yield* Deferred.succeed( + refs, + refsResult([ + { name: "feature/current", current: true, isDefault: false, worktreePath: null }, + { + name: "origin/develop", + current: false, + isDefault: true, + isRemote: true, + worktreePath: null, + }, + ]), + ); + expect(yield* Fiber.join(start)).toMatchObject({ threadId: automaticWorktreeTurn.threadId }); + expect(launches).toEqual([withBaseRef("origin/develop")]); + expect(commands).toEqual([]); + expect(automaticWorktreeTurn.bootstrap.prepareWorktree).not.toHaveProperty("baseBranch"); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER), Effect.scoped), + ); + + it.effect("passes automatic intent directly to capable servers without the client hint", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const lookups: VcsListRefsInput[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + listRefs: (input) => + Effect.sync(() => { + lookups.push(input); + return refsResult([]); + }), + }); + yield* startThreadTurn({ ...automaticWorktreeTurn, serverResolvesWorktreeBase: true }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + expect(lookups).toEqual([]); + expect(launches).toEqual([automaticWorktreeLaunch]); + expect(commands).toEqual([]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect( + "keeps independent command and thread identities for automatic multi-model launches", + () => + Effect.gen(function* () { + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const lookups: VcsListRefsInput[] = []; + const supervisor = yield* makeSupervisor({ + commands: [], + projects: [], + launches, + listRefs: (input) => + Effect.sync(() => { + lookups.push(input); + return refsResult([]); + }), + }); + for (const suffix of ["one", "two"]) { + yield* startThreadTurn({ + ...automaticWorktreeTurn, + commandId: CommandId.make(`multi-model-command-${suffix}`), + threadId: ThreadId.make(`multi-model-thread-${suffix}`), + serverResolvesWorktreeBase: true, + message: { + ...automaticWorktreeTurn.message, + messageId: MessageId.make(`multi-model-message-${suffix}`), + }, + bootstrap: { + ...automaticWorktreeTurn.bootstrap, + prepareWorktree: { + projectCwd: "/workspace/project", + requireWorktree: true, + startFromOrigin: true, + }, + }, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + } + expect(lookups).toEqual([]); + expect(launches).toEqual( + ["one", "two"].map((suffix) => ({ + ...automaticWorktreeLaunch, + commandId: CommandId.make(`multi-model-command-${suffix}`), + threadId: ThreadId.make(`multi-model-thread-${suffix}`), + initialMessage: { + ...automaticWorktreeLaunch.initialMessage, + messageId: MessageId.make(`multi-model-message-${suffix}`), + }, + workspaceStrategy: { type: "worktree", startFromOrigin: true }, + })), + ); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("preserves an explicit base and skips lookup for current-checkout launches", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const lookups: VcsListRefsInput[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + listRefs: (input) => + Effect.sync(() => { + lookups.push(input); + return refsResult([]); + }), + }); + yield* startThreadTurn({ + ...automaticWorktreeTurn, + serverResolvesWorktreeBase: false, + bootstrap: { + ...automaticWorktreeTurn.bootstrap, + prepareWorktree: { + ...automaticWorktreeTurn.bootstrap.prepareWorktree, + baseBranch: "upstream/release", + }, + }, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + yield* startThreadTurn({ + ...automaticWorktreeTurn, + serverResolvesWorktreeBase: false, + bootstrap: { createThread: automaticWorktreeTurn.bootstrap.createThread }, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(lookups).toEqual([]); + expect(launches).toEqual([ + withBaseRef("upstream/release"), + { ...automaticWorktreeLaunch, workspaceStrategy: { type: "root" } }, + ]); + expect(commands).toEqual([]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("uses the current local branch when an older server reports no default", () => + Effect.gen(function* () { + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const supervisor = yield* makeSupervisor({ + commands: [], + projects: [], + launches, + listRefs: () => + Effect.succeed( + refsResult([ + { name: "feature/local", current: true, isDefault: false, worktreePath: null }, + ]), + ), + }); + yield* startThreadTurn({ ...automaticWorktreeTurn, serverResolvesWorktreeBase: false }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + expect(launches).toEqual([withBaseRef("feature/local")]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("fails clearly without launching when no valid automatic base exists", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + for (const isRepo of [true, false]) { + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + listRefs: () => Effect.succeed(refsResult([], isRepo)), + }); + const failure = yield* startThreadTurn(automaticWorktreeTurn).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.flip, + ); + expect(failure).toBeInstanceOf(OrchestrationDispatchCommandError); + expect(failure.message).toContain("base branch"); + } + expect(launches).toEqual([]); + expect(commands).toEqual([]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("propagates lookup failure without launching or retrying", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + let lookupCount = 0; + const refsFailure = new GitCommandError({ + operation: "listRefs", + command: "git", + cwd: "/workspace/project", + detail: "Repository unavailable", + }); + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + launches, + listRefs: () => + Effect.suspend(() => { + lookupCount += 1; + return Effect.fail(refsFailure); + }), + }); + const failure = yield* startThreadTurn(automaticWorktreeTurn).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.flip, + ); + expect(failure).toBe(refsFailure); + expect(lookupCount).toBe(1); + expect(launches).toEqual([]); + expect(commands).toEqual([]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); +}); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index bedf552f9..05ff7c423 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -6,6 +6,7 @@ import { CheckpointScopeId, ORCHESTRATION_V2_WS_METHODS, OrchestrationV2CheckpointUnavailableError, + OrchestrationDispatchCommandError, WS_METHODS, type ChatAttachment, type MessageId, @@ -26,6 +27,7 @@ import { type ThreadEnvMode, type UploadChatAttachment, } from "@t3tools/contracts"; +import { resolveDefaultWorktreeBaseBranch } from "@t3tools/shared/git"; import { modelSelectionCommandType } from "@t3tools/shared/model"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as Crypto from "effect/Crypto"; @@ -155,7 +157,7 @@ interface StartThreadBootstrap { /** V2 worktree launches always fail rather than falling back to the project checkout. */ readonly requireWorktree?: boolean; readonly projectCwd: string; - readonly baseBranch: string; + readonly baseBranch?: string; readonly branch?: string; readonly startFromOrigin?: boolean; }; @@ -163,6 +165,8 @@ interface StartThreadBootstrap { } export interface StartThreadTurnInput extends ThreadCommandInput { + /** Client-only capability hint; never sent in the launch payload. */ + readonly serverResolvesWorktreeBase?: boolean; readonly manualContinuationOfRunId?: RunId; readonly message: { readonly messageId: MessageId; @@ -637,7 +641,27 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( attachments, ); const bootstrap = input.bootstrap?.createThread; - const prepareWorktree = input.bootstrap?.prepareWorktree; + let prepareWorktree = input.bootstrap?.prepareWorktree; + if ( + prepareWorktree !== undefined && + prepareWorktree.baseBranch === undefined && + input.serverResolvesWorktreeBase !== true + ) { + const result = yield* request(WS_METHODS.vcsListRefs, { + cwd: prepareWorktree.projectCwd, + limit: 100, + }); + const baseBranch = result.isRepo ? resolveDefaultWorktreeBaseBranch(result.refs) : null; + if (baseBranch === null) { + return yield* Effect.fail( + new OrchestrationDispatchCommandError({ + message: + "Unable to select a base branch for the new worktree. Choose a base ref and retry.", + }), + ); + } + prepareWorktree = { ...prepareWorktree, baseBranch }; + } if (bootstrap !== undefined || prepareWorktree !== undefined) { const existingProjection = bootstrap === undefined ? yield* getProjection(input.threadId) : null; @@ -646,7 +670,9 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( prepareWorktree !== undefined ? { type: "worktree" as const, - baseRef: prepareWorktree.baseBranch, + ...(prepareWorktree.baseBranch === undefined + ? {} + : { baseRef: prepareWorktree.baseBranch }), ...(prepareWorktree.branch === undefined ? {} : { branch: prepareWorktree.branch }), ...(prepareWorktree.startFromOrigin === undefined ? {} @@ -708,7 +734,13 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( }); } - const serverResolvesCommandContext = yield* supportsServerResolvedCommandContext(); + const capabilities = (yield* getInitialServerConfig()).environment.capabilities; + const serverResolvesCommandContext = capabilities.serverResolvedCommandContext === true; + const creationSource = input.creationSource ?? "web"; + const queuedToolBoundaryEligible = + (creationSource === "web" || creationSource === "mobile") && + (requestedMode === "queue" || requestedMode === "auto") && + capabilities.queuedToolBoundaryDelivery === true; const projection = serverResolvesCommandContext ? null : yield* getProjection(input.threadId); const activeRun = projection?.runs.findLast( (run) => @@ -768,6 +800,7 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( ? { deliveryIntent: requestedMode } : {}), dispatchMode, + ...(queuedToolBoundaryEligible ? { queuedToolBoundaryEligible: true } : {}), }); }); diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 8dda4440e..f97ca38e7 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -34,6 +34,7 @@ import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { + EnvironmentRpcUnavailableError, EnvironmentRpcRequestObserver, request, runStream, @@ -309,6 +310,122 @@ describe("environment RPC", () => { }), ); + it.effect.each(["validation failure", "disconnected"] as const)( + "keeps %s local without observing, dispatching, or retrying a request", + (reason) => + Effect.gen(function* () { + let validations = 0; + let requests = 0; + let observations = 0; + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Effect.sync(() => { + requests += 1; + return { status: "available", version: "2026.6.0" }; + }), + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + if (reason === "validation failure") { + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + } + + const failure = yield* request( + WS_METHODS.cloudGetRelayClientStatus, + {}, + { + validateSession: () => + Effect.sync(() => { + validations += 1; + }).pipe(Effect.andThen(Effect.fail("validation failed"))), + }, + ).pipe( + Effect.flip, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: () => + Effect.sync(() => { + observations += 1; + return Effect.void; + }), + }), + ), + ); + + if (reason === "validation failure") { + expect(failure).toBe("validation failed"); + expect(validations).toBe(1); + } else { + expect(failure).toBeInstanceOf(EnvironmentRpcUnavailableError); + expect(validations).toBe(0); + } + expect(requests).toBe(0); + expect(observations).toBe(0); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect.each(["failure", "interruption"] as const)( + "finalizes a validated unary request observation after %s without retrying", + (reason) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const observations: string[] = []; + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.andThen( + Effect.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "socket closed", + cause: new Error("socket closed"), + }), + }), + ), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const requestFiber = yield* request( + WS_METHODS.cloudGetRelayClientStatus, + {}, + { + validateSession: () => Effect.void, + }, + ).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: () => + Effect.sync(() => { + observations.push("start"); + return Effect.sync(() => { + observations.push("finish"); + }); + }), + }), + ), + Effect.forkChild, + ); + yield* Deferred.await(started); + expect(observations).toEqual(["start"]); + if (reason === "failure") { + yield* Deferred.succeed(finish, undefined); + expect(Exit.isFailure(yield* Fiber.await(requestFiber))).toBe(true); + } else { + yield* Fiber.interrupt(requestFiber); + } + expect(observations).toEqual(["start", "finish"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + it.effect("binds finite streaming commands to one active session", () => Effect.gen(function* () { const firstEvents = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4ce45cf3f..632f777c7 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -150,13 +150,24 @@ export const getInitialServerConfig = Effect.fn("EnvironmentRpc.getInitialServer export const request = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, ->(tag: TTag, input: EnvironmentRpcInput) { + E = never, + R = never, +>( + tag: TTag, + input: EnvironmentRpcInput, + options?: { + readonly validateSession: (session: RpcSession) => Effect.Effect; + }, +) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; yield* Effect.annotateCurrentSpan({ "environment.id": supervisor.target.environmentId, "rpc.method": tag, }); const session = yield* currentSession(); + if (options !== undefined) { + yield* options.validateSession(session); + } const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 5b319eb10..30f5e0083 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -86,6 +86,7 @@ function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolea } export interface EnvironmentThreadShell { + readonly runtimeIdentity?: OrchestrationV2ThreadShell["runtimeIdentity"]; readonly environmentId: EnvironmentId; readonly id: ThreadId; readonly projectId: ProjectId; @@ -240,6 +241,7 @@ export function presentThreadShell( lineage: thread.lineage, forkedFrom: thread.forkedFrom, activeProviderThreadId: thread.activeProviderThreadId, + ...(thread.runtimeIdentity === undefined ? {} : { runtimeIdentity: thread.runtimeIdentity }), latestRun, runtime: shellRuntime(thread), latestUserMessageAt: nullableIso(thread.latestUserMessageAt), diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index ad6f4f9e1..970757da1 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -218,6 +218,11 @@ export function createPullRequestEnvironmentAtoms( refreshIntervalMs: 10_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), + ciStatus: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:ci-status", + tag: WS_METHODS.pullRequestsCiStatus, + staleTimeMs: 60_000, + }), list: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:list", tag: WS_METHODS.pullRequestsList, diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index ef72946f5..29bdda49d 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -705,7 +705,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily< }); } -export function createEnvironmentRpcCommand( +export function createEnvironmentRpcCommand( runtime: Atom.AtomRuntime, options: { readonly label: string; @@ -714,7 +714,7 @@ export function createEnvironmentRpcCommand, ) => Effect.Effect< EnvironmentRpcSuccess, - EnvironmentRpcFailure | EnvironmentRpcUnavailableError, + EnvironmentRpcFailure | EnvironmentRpcUnavailableError | E, EnvironmentSupervisor.EnvironmentSupervisor | EnvironmentRegistry.EnvironmentRegistry >; readonly scheduler?: AtomCommandScheduler; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5f1818399..8a0bad0f7 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -40,6 +40,7 @@ import * as Persistence from "../platform/persistence.ts"; import { runCachePersistence } from "./cachePersistence.ts"; import { isRpcClientError, + EnvironmentRpcUnavailableError, request, runStream, subscribe, @@ -1107,6 +1108,27 @@ export function createServerEnvironmentAtoms( staleTimeMs: 0, idleTtlMs: 0, }), + readTokenAccounting: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:read-token-accounting", + tag: WS_METHODS.serverReadTokenAccounting, + execute: (input) => + request(WS_METHODS.serverReadTokenAccounting, input, { + validateSession: (session) => + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const config = yield* session.initialConfig; + if (config.environment.capabilities.savedTokenAccounting !== true) { + return yield* Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: "This environment does not advertise a saved accounting reader.", + }), + ); + } + }), + }), + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), configProjection, welcome, legacyThreadMigration: createEnvironmentRpcSubscriptionAtomFamily(runtime, { diff --git a/packages/client-runtime/src/state/serverTokenAccounting.test.ts b/packages/client-runtime/src/state/serverTokenAccounting.test.ts new file mode 100644 index 000000000..b92c01f8a --- /dev/null +++ b/packages/client-runtime/src/state/serverTokenAccounting.test.ts @@ -0,0 +1,251 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + type ServerConfig, + type TokenAccountingReadResult, + WS_METHODS, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createServerEnvironmentAtoms } from "./server.ts"; + +const target = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("accounting-environment"), + label: "Accounting environment", + httpBaseUrl: "https://accounting.example.test", + wsBaseUrl: "wss://accounting.example.test", +}); +const result: TokenAccountingReadResult = { + state: "unavailable", + status: "missing", + reason: "configured_report_missing", + configuredReportId: "a".repeat(64), + readAt: "2026-10-02T12:00:00Z", +}; + +const makeHarness = Effect.fn("ServerTokenAccountingTest.makeHarness")(function* ( + supported = true, + connected = true, +) { + const config = { + settings: DEFAULT_SERVER_SETTINGS, + environment: { + serverVersion: "0.0.1", + capabilities: supported ? { savedTokenAccounting: true } : {}, + }, + } as ServerConfig; + let reads = 0; + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.make({ version: 1, type: "snapshot", config }), + [WS_METHODS.serverReadTokenAccounting]: () => + Effect.sync(() => { + reads += 1; + return result; + }), + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.succeed(config), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const sessionRef = yield* SubscriptionRef.make( + connected ? Option.some(session) : Option.none(), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target, + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: connected ? "connected" : "available", + }), + session: sessionRef, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const cache = EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), + Layer.succeed(EnvironmentCacheStore, cache), + ), + ); + const atoms = createServerEnvironmentAtoms(runtime, { + initialConfigValueAtom: () => Atom.make(config), + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + return { atoms, registry, sessionRef, session, config, reads: () => reads }; +}); + +it.effect("reads saved accounting only after an explicit command and rereads explicitly", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + expect(harness.reads()).toBe(0); + const first = yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ); + expect(first._tag).toBe("Success"); + if (first._tag === "Success") expect(first.value).toEqual(result); + expect(harness.reads()).toBe(1); + yield* SubscriptionRef.set(harness.sessionRef, Option.none()); + yield* SubscriptionRef.set(harness.sessionRef, Option.some(harness.session)); + expect(harness.reads()).toBe(1); + yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ); + expect(harness.reads()).toBe(2); + }), + ), +); + +it.effect("does not dispatch to a server that omits the optional reader capability", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(false); + const read = yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ); + expect(read._tag).toBe("Failure"); + expect(harness.reads()).toBe(0); + }), + ), +); + +it.effect("keeps saved accounting validation and dispatch on the same session", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + const validationStarted = yield* Deferred.make(); + const finishValidation = yield* Deferred.make(); + const validatedSession: RpcSession = { + ...harness.session, + initialConfig: Effect.gen(function* () { + yield* Deferred.succeed(validationStarted, undefined); + yield* Deferred.await(finishValidation); + return harness.config; + }), + }; + let replacementReads = 0; + const replacementConfig: ServerConfig = { + ...harness.config, + environment: { + ...harness.config.environment, + capabilities: { + ...harness.config.environment.capabilities, + savedTokenAccounting: false, + }, + }, + }; + const replacementSession: RpcSession = { + ...harness.session, + initialConfig: Effect.succeed(replacementConfig), + client: { + [WS_METHODS.serverReadTokenAccounting]: () => + Effect.sync(() => { + replacementReads += 1; + return result; + }), + } as unknown as WsRpcProtocolClient, + }; + yield* SubscriptionRef.set(harness.sessionRef, Option.some(validatedSession)); + const readFiber = yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ).pipe(Effect.forkChild); + yield* Effect.raceFirst( + Deferred.await(validationStarted), + Fiber.join(readFiber).pipe( + Effect.flatMap((read) => + Effect.die( + new Error( + read._tag === "Failure" + ? Cause.pretty(read.cause) + : "Saved accounting completed before capability validation.", + ), + ), + ), + ), + ); + yield* SubscriptionRef.set(harness.sessionRef, Option.some(replacementSession)); + yield* Deferred.succeed(finishValidation, undefined); + const read = yield* Fiber.join(readFiber); + + expect(replacementReads).toBe(0); + expect(harness.reads()).toBe(1); + expect(read._tag).toBe("Success"); + if (read._tag === "Success") expect(read.value).toEqual(result); + }), + ), +); + +it.effect("keeps a disconnected reader failure local without dispatch", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(true, false); + const read = yield* Effect.promise(() => + harness.atoms.readTokenAccounting.run(harness.registry, { + environmentId: target.environmentId, + input: {}, + }), + ); + expect(read._tag).toBe("Failure"); + expect(harness.reads()).toBe(0); + }), + ), +); diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 0009f1a7a..ee51ea5ab 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -1,6 +1,8 @@ import { EnvironmentId, ProjectId, + ProviderDriverKind, + ProviderInstanceId, ThreadId, type OrchestrationV2ShellSnapshot, } from "@t3tools/contracts"; @@ -8,6 +10,7 @@ import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it } from "vite-plus/test"; +import { presentThreadShell } from "./models.ts"; import { PrimaryConnectionTarget } from "../connection/model.ts"; import { v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; @@ -199,3 +202,29 @@ describe("v2 thread shell lists", () => { } }); }); + +it("retains optional server-selected runtime identity without activating replayed generations", () => { + const identity = { + runtimeGeneration: "historical-native-process", + evidenceRevision: 4, + requested: { + providerInstanceId: ProviderInstanceId.make("codex"), + providerDriver: ProviderDriverKind.make("codex"), + model: "requested", + serviceTier: null, + }, + observed: { + backend: { status: "unknown" as const }, + model: { status: "observed" as const, value: "native", sourceEvent: "codex.thread/open" }, + account: { status: "unavailable" as const, reason: "Not reported." }, + serviceTier: { status: "unavailable" as const, reason: "Not reported." }, + }, + }; + const presented = presentThreadShell(environmentId, { + ...v2ThreadShell, + runtimeIdentity: identity, + }); + expect(presented.runtimeIdentity).toEqual(identity); + expect(presented.modelSelection).toEqual(v2ThreadShell.modelSelection); + expect(presentThreadShell(environmentId, v2ThreadShell)).not.toHaveProperty("runtimeIdentity"); +}); diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index 8ae001ea7..71614ba73 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -7,6 +7,64 @@ function completed(input: unknown, output?: unknown): T3ToolSummaryCall { } describe("summarizeT3ToolCalls", () => { + it("counts accepted self-settlement targets across replay without claiming settlement completed", () => { + const accepted = completed( + { clientRequestId: "request-1" }, + { status: "accepted", threadId: "thread-1", runId: "run-1", clientRequestId: "request-1" }, + ); + expect(summarizeT3ToolCalls("thread-settle", [accepted, accepted])).toEqual({ + label: "Requested settlement for 1 thread", + failedCount: 0, + }); + const second = completed( + { clientRequestId: "request-2" }, + { status: "accepted", threadId: "thread-2", runId: "run-2", clientRequestId: "request-2" }, + ); + expect(summarizeT3ToolCalls("thread-settle", [accepted, accepted, second])).toEqual({ + label: "Requested settlement for 2 threads", + failedCount: 0, + }); + }); + + it("keeps denied and unfinished self-settlement calls distinct from accepted requests", () => { + const denied = completed( + { clientRequestId: "denied-request" }, + { + structuredContent: { + _tag: "OrchestratorMcpFailure", + code: "capability_denied", + message: "Unavailable", + }, + }, + ); + const unfinished: T3ToolSummaryCall = { + input: { clientRequestId: "unfinished-request" }, + output: undefined, + outcome: "unfinished", + }; + expect(summarizeT3ToolCalls("thread-settle", [denied])).toEqual({ + label: "Tried to request settlement for 1 thread", + failedCount: 1, + }); + expect(summarizeT3ToolCalls("thread-settle", [unfinished])).toEqual({ + label: "Tried to request settlement for 1 thread", + failedCount: 0, + }); + const accepted = completed( + { clientRequestId: "accepted-request" }, + { + status: "accepted", + threadId: "thread-1", + runId: "run-1", + clientRequestId: "accepted-request", + }, + ); + expect(summarizeT3ToolCalls("thread-settle", [denied, unfinished, accepted])).toEqual({ + label: "Requested settlement for 1 thread", + failedCount: 1, + }); + }); + it("counts registered projects, repository destinations, and accepted thread launches", () => { expect( summarizeT3ToolCalls("project-create", [ diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index c6b40e5a6..a6176f680 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -168,6 +168,13 @@ export function summarizeT3ToolCalls( quantity(countEntities(threadIds), "thread"), ); break; + case "thread-settle": + label = phrase( + "Requested settlement for", + "request settlement for", + quantity(countEntities(threadIds), "thread"), + ); + break; case "task-status": label = phrase("Checked", "check", `task status ${times}`); break; diff --git a/packages/client-runtime/src/voice-review.test.ts b/packages/client-runtime/src/voice-review.test.ts new file mode 100644 index 000000000..d860e80b5 --- /dev/null +++ b/packages/client-runtime/src/voice-review.test.ts @@ -0,0 +1,160 @@ +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, VoiceReviewNotConfiguredError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { RelayConnectionTarget, type PreparedConnection } from "./connection/model.ts"; +import { RemoteEnvironmentAuthorization } from "./authorization/service.ts"; +import { ManagedRelayDpopSigner, type ManagedRelayDpopProofInput } from "./relay/managedRelay.ts"; +import { remoteHttpClientLayer } from "./rpc/http.ts"; +import { + fetchVoiceReviewDrafts, + fetchVoiceReviewDraft, + mutateVoiceReviewDraft, +} from "./voice-review.ts"; + +const target = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("fixture-environment"), + label: "Fixture", +}); +const prepared: PreparedConnection = { + environmentId: target.environmentId, + label: "Fixture", + target, + httpBaseUrl: "https://fixture.test", + socketUrl: "wss://fixture.test/ws", + httpAuthorization: { _tag: "Bearer", token: "fixture-session-token" }, +}; +const draft = { + id: "capture", + source_id: "microphone", + state: "paused", + revision: 2, + text: "literal text", + transcript_provider: "fixture", + language: null, + edited: false, + created_at: "2026-10-02T00:00:00Z", + updated_at: "2026-10-02T00:00:00Z", + due_at: null, + remaining_ms: 180000, + expires_at: "2026-10-03T00:00:00Z", + command_id: null, + command_status: null, + reason: null, + server_now: "2026-10-02T00:00:00Z", +}; +it.effect( + "uses authenticated environment HTTP for list/detail and mutation with snake_case payloads", + () => + Effect.gen(function* () { + const calls: Array<{ url: string; init: RequestInit }> = []; + const layer = remoteHttpClientLayer(async (url, init) => { + calls.push({ url: String(url), init: init ?? {} }); + if (String(url).includes("?")) + return Response.json({ server_now: draft.server_now, drafts: [draft] }); + return Response.json(init?.method === "POST" ? { draft, edit_handle: null } : draft); + }); + const input = { prepared, signer: Option.none() }; + const list = yield* fetchVoiceReviewDrafts({ ...input, scope: "recent", limit: 10 }).pipe( + Effect.provide(layer), + ); + expect(list.drafts).toEqual([draft]); + const detail = yield* fetchVoiceReviewDraft({ ...input, id: "capture" }).pipe( + Effect.provide(layer), + ); + expect(detail).toEqual(draft); + yield* mutateVoiceReviewDraft({ + ...input, + id: "capture", + action: "pause", + payload: { expected_revision: 2 }, + }).pipe(Effect.provide(layer)); + expect(calls[0]?.url).toBe( + "https://fixture.test/api/voice-review/drafts?scope=recent&limit=10", + ); + expect(calls[2]?.url).toBe("https://fixture.test/api/voice-review/drafts/capture/pause"); + const body = yield* Effect.promise(() => new Response(calls[2]?.init.body).text()); + expect(body).toBe('{"expected_revision":2}'); + expect( + calls.every( + ({ init }) => + new Headers(init.headers).get("authorization") === "Bearer fixture-session-token", + ), + ).toBe(true); + }), +); +it.effect( + "exposes a closed not-configured error and leaves old servers as an explicit HTTP failure", + () => + Effect.gen(function* () { + const input = { prepared, signer: Option.none() }; + const closed = remoteHttpClientLayer(async () => + Response.json({ _tag: "VoiceReviewNotConfiguredError" }, { status: 503 }), + ); + const error = yield* fetchVoiceReviewDrafts(input).pipe(Effect.flip, Effect.provide(closed)); + expect(error).toBeInstanceOf(VoiceReviewNotConfiguredError); + const old = remoteHttpClientLayer(async () => new Response("Missing", { status: 404 })); + const oldError = yield* fetchVoiceReviewDrafts(input).pipe(Effect.flip, Effect.provide(old)); + expect(oldError).toMatchObject({ + _tag: "RemoteEnvironmentAuthUndeclaredStatusError", + status: 404, + }); + }), +); +it.effect( + "binds relay DPoP to the selected environment request and never retries ambiguous mutations", + () => + Effect.gen(function* () { + const proofs: ManagedRelayDpopProofInput[] = []; + let calls = 0; + const signer = ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("fixture-thumbprint"), + createProof: (input) => + Effect.sync(() => { + proofs.push(input); + return "fixture-proof"; + }), + }); + const remoteAuthorization = RemoteEnvironmentAuthorization.of({ + authorizeBearer: () => Effect.die("unexpected"), + authorizeDpop: () => Effect.die("unexpected"), + authorizeDpopHttp: () => + Effect.succeed({ + environmentId: target.environmentId, + label: "Fixture", + httpBaseUrl: "https://relay.test", + httpAuthorization: { + _tag: "Dpop", + accessToken: "fresh-token", + expiresAtEpochMs: 3600000, + }, + }), + }); + const layer = remoteHttpClientLayer(async (_url, init) => { + calls++; + expect(new Headers(init?.headers).get("authorization")).toBe("DPoP fresh-token"); + expect(new Headers(init?.headers).get("dpop")).toBe("fixture-proof"); + return Response.json({ _tag: "VoiceReviewUnavailableError" }, { status: 502 }); + }); + const error = yield* mutateVoiceReviewDraft({ + prepared: { + ...prepared, + httpAuthorization: { _tag: "Dpop", accessToken: "old", expiresAtEpochMs: 0 }, + }, + signer: Option.some(signer), + remoteAuthorization: Option.some(remoteAuthorization), + id: "capture", + action: "send-now", + payload: { expected_revision: 2 }, + }).pipe(Effect.flip, Effect.provide(layer)); + expect(error).toMatchObject({ _tag: "VoiceReviewUnavailableError" }); + expect(calls).toBe(1); + expect(proofs).toEqual([ + { + method: "POST", + url: "https://relay.test/api/voice-review/drafts/capture/send-now", + accessToken: "fresh-token", + }, + ]); + }), +); diff --git a/packages/client-runtime/src/voice-review.ts b/packages/client-runtime/src/voice-review.ts new file mode 100644 index 000000000..c75504f32 --- /dev/null +++ b/packages/client-runtime/src/voice-review.ts @@ -0,0 +1,125 @@ +import { + VoiceReviewError, + type VoiceReviewAction, + type VoiceReviewMutationPayload, + VoiceReviewEditSavePayload, + VoiceReviewEditCancelPayload, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { RemoteEnvironmentAuthorization } from "./authorization/service.ts"; +import type { PreparedConnection } from "./connection/model.ts"; +import type { ManagedRelayDpopSigner } from "./relay/managedRelay.ts"; +import { makeEnvironmentHttpApiUrlBuilder } from "./rpc/http.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "./state/environmentHttpAuth.ts"; + +export interface VoiceReviewClientOptions { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly remoteAuthorization?: Option.Option; + readonly timeoutMs?: number; +} +const isReviewError = Schema.is(VoiceReviewError); +const isWrappedFetchError = Schema.is( + Schema.Struct({ + _tag: Schema.Literal("RemoteEnvironmentAuthFetchError"), + cause: Schema.Unknown, + }), +); +const decodeEditSave = Schema.decodeUnknownEffect(VoiceReviewEditSavePayload); +const decodeEditCancel = Schema.decodeUnknownEffect(VoiceReviewEditCancelPayload); +const unwrapReviewError = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error) => { + if (isReviewError(error)) return error; + if (isWrappedFetchError(error) && isReviewError(error.cause)) return error.cause; + return error; + }), + ); +const authorization = (input: VoiceReviewClientOptions) => + input.remoteAuthorization === undefined + ? Effect.serviceOption(RemoteEnvironmentAuthorization) + : Effect.succeed(input.remoteAuthorization); + +export const fetchVoiceReviewDrafts = Effect.fn("clientRuntime.voiceReview.list")(function* ( + input: VoiceReviewClientOptions & { + readonly scope?: "pending" | "recent"; + readonly limit?: number; + }, +) { + const query = { scope: input.scope ?? "pending", limit: input.limit ?? 50 }; + return yield* unwrapReviewError( + executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization: yield* authorization(input), + group: "voiceReview", + method: "GET", + timeoutMs: input.timeoutMs ?? 15_000, + url: (base) => makeEnvironmentHttpApiUrlBuilder(base).voiceReview.list({ query }), + request: ({ client, headers }) => client.list({ query, headers }), + }), + ); +}); + +export const fetchVoiceReviewDraft = Effect.fn("clientRuntime.voiceReview.get")(function* ( + input: VoiceReviewClientOptions & { readonly id: string }, +) { + const params = { id: input.id }; + return yield* unwrapReviewError( + executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization: yield* authorization(input), + group: "voiceReview", + method: "GET", + timeoutMs: input.timeoutMs ?? 15_000, + url: (base) => makeEnvironmentHttpApiUrlBuilder(base).voiceReview.get({ params }), + request: ({ client, headers }) => client.get({ params, headers }), + }), + ); +}); + +export const mutateVoiceReviewDraft = Effect.fn("clientRuntime.voiceReview.mutate")(function* ( + input: VoiceReviewClientOptions & { + readonly id: string; + readonly action: VoiceReviewAction; + readonly payload: VoiceReviewMutationPayload; + }, +) { + const params = { id: input.id }; + const endpoint = { + pause: "pause", + play: "play", + "edit-begin": "editBegin", + "edit-save": "editSave", + "edit-cancel": "editCancel", + "send-now": "sendNow", + delete: "delete", + } as const; + return yield* unwrapReviewError( + executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization: yield* authorization(input), + group: "voiceReview", + method: "POST", + timeoutMs: input.timeoutMs ?? 25_000, + url: (base) => + makeEnvironmentHttpApiUrlBuilder(base).voiceReview[endpoint[input.action]]({ params }), + request: ({ client, headers }) => { + const args = { params, headers, payload: input.payload }; + switch (input.action) { + case "edit-save": + return Effect.flatMap(decodeEditSave(input.payload), (payload) => + client.editSave({ ...args, payload }), + ); + case "edit-cancel": + return Effect.flatMap(decodeEditCancel(input.payload), (payload) => + client.editCancel({ ...args, payload }), + ); + default: + return client[endpoint[input.action]](args); + } + }, + }), + ); +}); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2daf804aa..19e0e884c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -7,6 +7,10 @@ ], "type": "module", "exports": { + "./conversationLibrary": { + "types": "./src/conversationLibrary.ts", + "import": "./src/conversationLibrary.ts" + }, ".": { "types": "./src/index.ts", "import": "./src/index.ts" diff --git a/packages/contracts/src/conversationLibrary.test.ts b/packages/contracts/src/conversationLibrary.test.ts new file mode 100644 index 000000000..ffcdf8185 --- /dev/null +++ b/packages/contracts/src/conversationLibrary.test.ts @@ -0,0 +1,134 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + CONVERSATION_LIBRARY_PROTOCOL, + LibraryErrorCodeSchema, + LibraryReplySchema, + LibraryRequestSchema, + type LibraryRequest, +} from "./conversationLibrary.ts"; + +const decodeRequest = Schema.decodeUnknownSync(LibraryRequestSchema); +const decodeReply = Schema.decodeUnknownSync(LibraryReplySchema); +const decodeErrorCode = Schema.decodeUnknownSync(LibraryErrorCodeSchema); + +describe("conversation library runtime contract", () => { + it("decodes every existing request discriminator and rejects malformed variants", () => { + const requests = [ + { kind: "hello" }, + { kind: "accounts" }, + { kind: "createAccount", label: "Personal", workspace: "local" }, + { kind: "preview", conversations: [{ title: "Sample", mapping: {} }] }, + { kind: "import", accountId: "account-1", conversations: [{ title: "Sample", mapping: {} }] }, + { kind: "list", view: "unread" }, + { kind: "detail", key: "conversation-1", offset: 0 }, + { kind: "update", key: "conversation-1", pinned: true }, + { + kind: "selectSnapshot", + key: "conversation-1", + snapshotId: "snapshot-1", + expectedRevision: 1, + }, + { kind: "remove", key: "conversation-1", expectedRevision: 1 }, + ] satisfies readonly LibraryRequest[]; + + expect(requests.map((request) => decodeRequest(request).kind)).toEqual( + requests.map((request) => request.kind), + ); + expect(() => decodeRequest({ kind: "import", accountId: 1, conversations: [] })).toThrow(); + expect(() => decodeRequest({ kind: "unknown" })).toThrow(); + }); + + it("decodes every existing reply discriminator and its nested record shapes", () => { + const replies = [ + { + kind: "hello", + protocol: CONVERSATION_LIBRARY_PROTOCOL, + revision: 1, + canWrite: false, + capture: "not-enabled", + }, + { kind: "accounts", accounts: [{ id: "account-1", label: "Personal", workspace: "local" }] }, + { kind: "account", account: { id: "account-1", label: "Personal", workspace: "local" } }, + { + kind: "preview", + conversations: [ + { id: "conversation-1", title: "Sample", messageCount: 2, warningCount: 0 }, + ], + }, + { kind: "imported", inserted: 1, duplicates: 0, older: 0, conflicts: 0, revision: 2 }, + { kind: "list", rows: [], revision: 2, cursor: null }, + { + kind: "detail", + conversation: { + key: "key-1", + accountId: "account-1", + conversationId: "conversation-1", + title: "Sample", + snapshotId: "snapshot-1", + sourceUpdatedAt: null, + importedAt: 1, + revision: 2, + unread: false, + pinned: false, + archived: false, + attention: false, + conflicts: false, + messageCount: 1, + warningCount: 0, + }, + account: { id: "account-1", label: "Personal", workspace: "local" }, + snapshotId: "snapshot-1", + snapshotSourceUpdatedAt: null, + snapshotImportedAt: 1, + showHidden: false, + nodeId: null, + messages: [], + totalMessages: 0, + readThrough: 0, + offset: 0, + previousOffset: null, + nextOffset: null, + snapshots: [], + snapshotOffset: 0, + snapshotCount: 1, + branches: [], + branchOffset: 0, + branchCount: 0, + warnings: [], + }, + { kind: "updated", revision: 3 }, + { kind: "removed", revision: 4 }, + ] as const; + + expect(replies.map((reply) => decodeReply(reply).kind)).toEqual( + replies.map((reply) => reply.kind), + ); + expect(() => + decodeReply({ + kind: "hello", + protocol: CONVERSATION_LIBRARY_PROTOCOL, + revision: 1, + capture: "not-enabled", + }), + ).toThrow(); + }); + + it("validates the known library error codes", () => { + expect( + ["invalid", "too-large", "not-found", "conflict", "unsupported", "storage", "forbidden"].map( + (code) => decodeErrorCode(code), + ), + ).toEqual([ + "invalid", + "too-large", + "not-found", + "conflict", + "unsupported", + "storage", + "forbidden", + ]); + expect(() => decodeErrorCode("private-detail")).toThrow(); + }); +}); diff --git a/packages/contracts/src/conversationLibrary.ts b/packages/contracts/src/conversationLibrary.ts new file mode 100644 index 000000000..d72d27e58 --- /dev/null +++ b/packages/contracts/src/conversationLibrary.ts @@ -0,0 +1,419 @@ +import * as Schema from "effect/Schema"; + +/** The library is a read model of supplied records, never a coding provider. */ +export const CONVERSATION_LIBRARY_PROTOCOL = "t3.conversation-library.v1"; +export const CONVERSATION_LIBRARY_PATH = "/api/conversation-library"; +export const LIBRARY_MAX_REQUEST_BYTES = 16 * 1024 * 1024; +export const LIBRARY_MAX_CONVERSATIONS = 1_000; +export const LIBRARY_MAX_NODES = 10_000; +export const LIBRARY_MAX_TEXT_BYTES = 8 * 1024 * 1024; +export const LIBRARY_PAGE_SIZE = 50; + +export interface ExportMessage { + readonly id?: string | null; + readonly author: { readonly role: string }; + readonly create_time?: number | null; + readonly content?: { + readonly content_type?: string; + readonly parts?: readonly unknown[]; + readonly text?: string; + } | null; + readonly metadata?: { readonly is_visually_hidden_from_conversation?: boolean }; +} + +export interface ExportNode { + readonly id?: string; + readonly parent?: string | null; + readonly message?: ExportMessage | null; +} + +export interface ExportConversation { + readonly id?: string; + readonly conversation_id?: string; + readonly title: string; + readonly create_time?: number | null; + readonly update_time?: number | null; + readonly current_node?: string | null; + readonly mapping: Readonly>; +} + +export interface LibraryNode { + readonly id: string; + readonly parentId: string | null; + readonly messageId: string | null; + readonly role: string | null; + readonly text: string; + readonly createdAt: number | null; + readonly hidden: boolean; + readonly unsupportedParts: number; +} + +export interface LibrarySnapshot { + readonly conversationId: string; + readonly title: string; + readonly sourceUpdatedAt: number | null; + readonly currentNodeId: string | null; + readonly nodes: readonly LibraryNode[]; + readonly warnings: readonly string[]; +} + +export interface LibraryAccount { + readonly id: string; + readonly label: string; + readonly workspace: string; +} + +export type LibraryView = "all" | "unread" | "pinned" | "archived" | "attention"; + +export interface LibrarySummary { + readonly key: string; + readonly accountId: string; + readonly conversationId: string; + readonly title: string; + readonly snapshotId: string; + readonly sourceUpdatedAt: number | null; + readonly importedAt: number; + readonly revision: number; + readonly unread: boolean; + readonly pinned: boolean; + readonly archived: boolean; + readonly attention: boolean; + readonly conflicts: boolean; + readonly messageCount: number; + readonly warningCount: number; +} + +export interface LibrarySnapshotSummary { + readonly id: string; + readonly sourceUpdatedAt: number | null; + readonly importedAt: number; + readonly messageCount: number; +} + +export type LibraryRequest = + | { readonly kind: "hello" } + | { readonly kind: "accounts" } + | { readonly kind: "createAccount"; readonly label: string; readonly workspace: string } + | { readonly kind: "preview"; readonly conversations: readonly ExportConversation[] } + | { + readonly kind: "import"; + readonly accountId: string; + readonly conversations: readonly ExportConversation[]; + } + | { + readonly kind: "list"; + readonly accountId?: string; + readonly query?: string; + readonly view?: LibraryView; + readonly cursor?: string; + } + | { + readonly kind: "detail"; + readonly key: string; + readonly snapshotId?: string; + readonly nodeId?: string; + readonly offset?: number; + readonly snapshotOffset?: number; + readonly branchOffset?: number; + readonly showHidden?: boolean; + } + | { + readonly kind: "update"; + readonly key: string; + readonly pinned?: boolean; + readonly archived?: boolean; + readonly attention?: boolean; + readonly readThrough?: number; + } + | { + readonly kind: "selectSnapshot"; + readonly key: string; + readonly snapshotId: string; + readonly expectedRevision: number; + } + | { readonly kind: "remove"; readonly key: string; readonly expectedRevision: number }; + +export interface LibraryDetail { + readonly kind: "detail"; + readonly conversation: LibrarySummary; + readonly account: LibraryAccount; + readonly snapshotId: string; + readonly snapshotSourceUpdatedAt: number | null; + readonly snapshotImportedAt: number; + readonly showHidden: boolean; + readonly nodeId: string | null; + readonly messages: readonly LibraryNode[]; + readonly totalMessages: number; + readonly readThrough: number; + readonly offset: number; + readonly previousOffset: number | null; + readonly nextOffset: number | null; + readonly snapshots: readonly LibrarySnapshotSummary[]; + readonly snapshotOffset: number; + readonly snapshotCount: number; + readonly branches: readonly { readonly id: string; readonly preview: string }[]; + readonly branchOffset: number; + readonly branchCount: number; + readonly warnings: readonly string[]; +} + +export type LibraryReply = + | { + readonly kind: "hello"; + readonly protocol: typeof CONVERSATION_LIBRARY_PROTOCOL; + readonly revision: number; + readonly canWrite: boolean; + readonly capture: "not-enabled"; + } + | { readonly kind: "accounts"; readonly accounts: readonly LibraryAccount[] } + | { readonly kind: "account"; readonly account: LibraryAccount } + | { + readonly kind: "preview"; + readonly conversations: readonly { + readonly id: string; + readonly title: string; + readonly messageCount: number; + readonly warningCount: number; + }[]; + } + | { + readonly kind: "imported"; + readonly inserted: number; + readonly duplicates: number; + readonly older: number; + readonly conflicts: number; + readonly revision: number; + } + | { + readonly kind: "list"; + readonly rows: readonly LibrarySummary[]; + readonly revision: number; + readonly cursor: string | null; + } + | LibraryDetail + | { readonly kind: "updated"; readonly revision: number } + | { readonly kind: "removed"; readonly revision: number }; + +export type LibraryErrorCode = + | "invalid" + | "too-large" + | "not-found" + | "conflict" + | "unsupported" + | "storage" + | "forbidden"; + +const ExportMessageSchema = Schema.Struct({ + id: Schema.optionalKey(Schema.NullOr(Schema.String)), + author: Schema.Struct({ role: Schema.String }), + create_time: Schema.optionalKey(Schema.NullOr(Schema.Number)), + content: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + content_type: Schema.optionalKey(Schema.String), + parts: Schema.optionalKey(Schema.Array(Schema.Unknown)), + text: Schema.optionalKey(Schema.String), + }), + ), + ), + metadata: Schema.optionalKey( + Schema.Struct({ + is_visually_hidden_from_conversation: Schema.optionalKey(Schema.Boolean), + }), + ), +}); + +const ExportNodeSchema = Schema.Struct({ + id: Schema.optionalKey(Schema.String), + parent: Schema.optionalKey(Schema.NullOr(Schema.String)), + message: Schema.optionalKey(Schema.NullOr(ExportMessageSchema)), +}); + +const ExportConversationSchema = Schema.Struct({ + id: Schema.optionalKey(Schema.String), + conversation_id: Schema.optionalKey(Schema.String), + title: Schema.String, + create_time: Schema.optionalKey(Schema.NullOr(Schema.Number)), + update_time: Schema.optionalKey(Schema.NullOr(Schema.Number)), + current_node: Schema.optionalKey(Schema.NullOr(Schema.String)), + mapping: Schema.Record(Schema.String, ExportNodeSchema), +}); + +const LibraryNodeSchema = Schema.Struct({ + id: Schema.String, + parentId: Schema.NullOr(Schema.String), + messageId: Schema.NullOr(Schema.String), + role: Schema.NullOr(Schema.String), + text: Schema.String, + createdAt: Schema.NullOr(Schema.Number), + hidden: Schema.Boolean, + unsupportedParts: Schema.Number, +}); + +const LibraryAccountSchema = Schema.Struct({ + id: Schema.String, + label: Schema.String, + workspace: Schema.String, +}); + +export const LibraryViewSchema = Schema.Literals([ + "all", + "unread", + "pinned", + "archived", + "attention", +]); + +const LibrarySummarySchema = Schema.Struct({ + key: Schema.String, + accountId: Schema.String, + conversationId: Schema.String, + title: Schema.String, + snapshotId: Schema.String, + sourceUpdatedAt: Schema.NullOr(Schema.Number), + importedAt: Schema.Number, + revision: Schema.Number, + unread: Schema.Boolean, + pinned: Schema.Boolean, + archived: Schema.Boolean, + attention: Schema.Boolean, + conflicts: Schema.Boolean, + messageCount: Schema.Number, + warningCount: Schema.Number, +}); + +const LibrarySnapshotSummarySchema = Schema.Struct({ + id: Schema.String, + sourceUpdatedAt: Schema.NullOr(Schema.Number), + importedAt: Schema.Number, + messageCount: Schema.Number, +}); + +const LibraryDetailSchema = Schema.Struct({ + kind: Schema.Literal("detail"), + conversation: LibrarySummarySchema, + account: LibraryAccountSchema, + snapshotId: Schema.String, + snapshotSourceUpdatedAt: Schema.NullOr(Schema.Number), + snapshotImportedAt: Schema.Number, + showHidden: Schema.Boolean, + nodeId: Schema.NullOr(Schema.String), + messages: Schema.Array(LibraryNodeSchema), + totalMessages: Schema.Number, + readThrough: Schema.Number, + offset: Schema.Number, + previousOffset: Schema.NullOr(Schema.Number), + nextOffset: Schema.NullOr(Schema.Number), + snapshots: Schema.Array(LibrarySnapshotSummarySchema), + snapshotOffset: Schema.Number, + snapshotCount: Schema.Number, + branches: Schema.Array(Schema.Struct({ id: Schema.String, preview: Schema.String })), + branchOffset: Schema.Number, + branchCount: Schema.Number, + warnings: Schema.Array(Schema.String), +}); + +export const LibraryRequestSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("hello") }), + Schema.Struct({ kind: Schema.Literal("accounts") }), + Schema.Struct({ + kind: Schema.Literal("createAccount"), + label: Schema.String, + workspace: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("preview"), + conversations: Schema.Array(ExportConversationSchema), + }), + Schema.Struct({ + kind: Schema.Literal("import"), + accountId: Schema.String, + conversations: Schema.Array(ExportConversationSchema), + }), + Schema.Struct({ + kind: Schema.Literal("list"), + accountId: Schema.optionalKey(Schema.String), + query: Schema.optionalKey(Schema.String), + view: Schema.optionalKey(LibraryViewSchema), + cursor: Schema.optionalKey(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("detail"), + key: Schema.String, + snapshotId: Schema.optionalKey(Schema.String), + nodeId: Schema.optionalKey(Schema.String), + offset: Schema.optionalKey(Schema.Number), + snapshotOffset: Schema.optionalKey(Schema.Number), + branchOffset: Schema.optionalKey(Schema.Number), + showHidden: Schema.optionalKey(Schema.Boolean), + }), + Schema.Struct({ + kind: Schema.Literal("update"), + key: Schema.String, + pinned: Schema.optionalKey(Schema.Boolean), + archived: Schema.optionalKey(Schema.Boolean), + attention: Schema.optionalKey(Schema.Boolean), + readThrough: Schema.optionalKey(Schema.Number), + }), + Schema.Struct({ + kind: Schema.Literal("selectSnapshot"), + key: Schema.String, + snapshotId: Schema.String, + expectedRevision: Schema.Number, + }), + Schema.Struct({ + kind: Schema.Literal("remove"), + key: Schema.String, + expectedRevision: Schema.Number, + }), +]) satisfies Schema.Schema; + +export const LibraryReplySchema = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("hello"), + protocol: Schema.Literal(CONVERSATION_LIBRARY_PROTOCOL), + revision: Schema.Number, + canWrite: Schema.Boolean, + capture: Schema.Literal("not-enabled"), + }), + Schema.Struct({ kind: Schema.Literal("accounts"), accounts: Schema.Array(LibraryAccountSchema) }), + Schema.Struct({ kind: Schema.Literal("account"), account: LibraryAccountSchema }), + Schema.Struct({ + kind: Schema.Literal("preview"), + conversations: Schema.Array( + Schema.Struct({ + id: Schema.String, + title: Schema.String, + messageCount: Schema.Number, + warningCount: Schema.Number, + }), + ), + }), + Schema.Struct({ + kind: Schema.Literal("imported"), + inserted: Schema.Number, + duplicates: Schema.Number, + older: Schema.Number, + conflicts: Schema.Number, + revision: Schema.Number, + }), + Schema.Struct({ + kind: Schema.Literal("list"), + rows: Schema.Array(LibrarySummarySchema), + revision: Schema.Number, + cursor: Schema.NullOr(Schema.String), + }), + LibraryDetailSchema, + Schema.Struct({ kind: Schema.Literal("updated"), revision: Schema.Number }), + Schema.Struct({ kind: Schema.Literal("removed"), revision: Schema.Number }), +]) satisfies Schema.Schema; + +export const LibraryErrorCodeSchema = Schema.Literals([ + "invalid", + "too-large", + "not-found", + "conflict", + "unsupported", + "storage", + "forbidden", +]) satisfies Schema.Schema; diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 3f624adf6..604197fee 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -14,6 +14,37 @@ const descriptor = { } as const; describe("ExecutionEnvironmentDescriptor", () => { + it("keeps queued tool delivery unsupported unless explicitly advertised", () => { + expect(decodeDescriptor(descriptor).capabilities.queuedToolBoundaryDelivery).toBeUndefined(); + for (const enabled of [true, false]) + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, queuedToolBoundaryDelivery: enabled }, + }).capabilities.queuedToolBoundaryDelivery, + ).toBe(enabled); + }); + + it("omits saved accounting on older servers and preserves explicit support", () => { + expect(decodeDescriptor(descriptor).capabilities.savedTokenAccounting).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, savedTokenAccounting: true }, + }).capabilities.savedTokenAccounting, + ).toBe(true); + }); + + it("preserves automatic worktree base capability while accepting older servers", () => { + expect(decodeDescriptor(descriptor).capabilities.worktreeDefaultBase).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, worktreeDefaultBase: true }, + }).capabilities.worktreeDefaultBase, + ).toBe(true); + }); + it("requires an advertised required-worktree bootstrap capability", () => { expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined(); expect( diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9a31f0888..ee171801a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -88,9 +88,31 @@ export const ServerSelfUpdateCapability = Schema.Literals([ ]); export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; +const nativeBootstrapCapabilityStruct = (fields: Fields) => { + const schema = Schema.Struct(fields); + // Flipped checks validate original wire keys that ordinary struct decoding would strip. + return Schema.flip( + Schema.flip(schema).check( + Schema.makeFilter((value) => + Reflect.ownKeys(value).every((key) => Object.hasOwn(fields, key)), + ), + ), + ); +}; + +export const NativeBootstrapCreationCapability = nativeBootstrapCapabilityStruct({ + submissionSchema: Schema.Literal("t3.native-bootstrap-submission/v1"), + preparationSchema: Schema.Literal("voice.t3-bootstrap-preparation/v1"), + observationSchema: Schema.Literal("t3.native-creation-observation/v1"), + guardRequired: Schema.Literal(true), +}); +export type NativeBootstrapCreationCapability = typeof NativeBootstrapCreationCapability.Type; + export const ExecutionEnvironmentCapabilities = Schema.Struct({ + nativeBootstrapCreation: Schema.optionalKey(NativeBootstrapCreationCapability), repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), + worktreeDefaultBase: Schema.optionalKey(Schema.Boolean), /** Missing on older servers, which still accept inline image attachments. */ attachmentUploads: Schema.optionalKey(Schema.Boolean), /** Uploaded files may accompany question answers. */ @@ -136,6 +158,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ usageLimitSources: Schema.optionalKey(Schema.Boolean), /** Server persists custom model rates and applies them to usage summaries. */ usagePriceOverrides: Schema.optionalKey(Schema.Boolean), + /** An enrolled adapter can explicitly read one configured, canonically validated saved report. */ + savedTokenAccounting: Schema.optionalKey(Schema.Boolean), /** Server persists model mappings and folds mapped usage into the target model. */ usageModelAliases: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pin / thread.unpin commands. Same @@ -163,6 +187,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ identified rollback readiness. Clients retain projection-based command shaping and validation when this is absent. */ serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), + /** Opted-in owner queues can deliver at successful foreground tool boundaries. */ + queuedToolBoundaryDelivery: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pull-request.watch and wakes agents on pull request changes. */ threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index ac7047c32..609c142af 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -1,3 +1,20 @@ +import { ProviderInstanceId } from "./providerInstance.ts"; +import { MessageId } from "./baseSchemas.ts"; +import { CommandId } from "./baseSchemas.ts"; +import { + ProviderQueueInventory, + ProviderQueueRefreshResult, + OrchestrationCommandObservation, +} from "./providerQueue.ts"; +import { + VoiceReviewDraft, + VoiceReviewDraftList, + VoiceReviewMutationResult, + VoiceReviewErrors, + VoiceReviewRevisionPayload, + VoiceReviewEditSavePayload, + VoiceReviewEditCancelPayload, +} from "./voiceReview.ts"; import * as Context from "effect/Context"; import type * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; @@ -5,8 +22,10 @@ import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware"; +import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { HostStatusSnapshot } from "./hostStatus.ts"; import { AuthAccessTokenResult, @@ -58,6 +77,13 @@ import { RelayLinkProofRequest, } from "./relay.ts"; +import { + CONVERSATION_LIBRARY_PATH, + LibraryErrorCodeSchema, + LibraryReplySchema, + LibraryRequestSchema, +} from "./conversationLibrary.ts"; + const OptionalBearerHeaders = Schema.Struct({ authorization: Schema.optionalKey(Schema.String), dpop: Schema.optionalKey(Schema.String), @@ -78,6 +104,8 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([ "scope_not_granted", "invalid_command", "invalid_history_cursor", + "dispatch_guard_bootstrap_unsupported", + "dispatch_guard_rejected", ]); export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type; @@ -109,6 +137,8 @@ export const EnvironmentInternalErrorReason = Schema.Literals([ "orchestration_thread_snapshot_failed", "orchestration_thread_bounded_snapshot_failed", "orchestration_thread_history_failed", + "orchestration_dispatch_failed", + "orchestration_command_observation_failed", "internal_error", ]); export type EnvironmentInternalErrorReason = typeof EnvironmentInternalErrorReason.Type; @@ -356,6 +386,73 @@ const EnvironmentProjectMutationErrors = [ EnvironmentInternalError, ] as const; +const EnvironmentConversationLibraryInvalidError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("invalid"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(400)); + +const EnvironmentConversationLibraryForbiddenError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("forbidden"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(403)); + +const EnvironmentConversationLibraryNotFoundError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("not-found"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(404)); + +const EnvironmentConversationLibraryConflictError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("conflict"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(409)); + +const EnvironmentConversationLibraryTooLargeError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("too-large"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(413)); + +const EnvironmentConversationLibraryStorageError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("storage"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(500)); + +const EnvironmentConversationLibraryUnsupportedError = Schema.Struct({ + kind: Schema.Literal("error"), + code: Schema.Literal("unsupported"), + message: Schema.String, + traceId: TrimmedNonEmptyString, +}).pipe(HttpApiSchema.status(501)); + +const EnvironmentConversationLibraryErrorSchemas = [ + EnvironmentConversationLibraryInvalidError, + EnvironmentConversationLibraryForbiddenError, + EnvironmentConversationLibraryNotFoundError, + EnvironmentConversationLibraryConflictError, + EnvironmentConversationLibraryTooLargeError, + EnvironmentConversationLibraryStorageError, + EnvironmentConversationLibraryUnsupportedError, +] as const; + +export const EnvironmentConversationLibraryErrorSchema = Schema.Union( + EnvironmentConversationLibraryErrorSchemas, +); +export type EnvironmentConversationLibraryError = + typeof EnvironmentConversationLibraryErrorSchema.Type; + +export const EnvironmentConversationLibraryErrorCode = LibraryErrorCodeSchema; + export interface EnvironmentSessionPrincipalShape { readonly sessionId: AuthSessionId; readonly subject: string; @@ -523,7 +620,58 @@ const EnvironmentOrchestrationThreadHistoryErrors = [ EnvironmentInternalError, ] as const; -class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") +export const ProviderGoalStateObservation = Schema.Struct({ + schema: Schema.Literal("t3.provider-goal-state/v1"), + threadId: ThreadId, + providerInstanceId: ProviderInstanceId, + nativeThreadId: Schema.NullOr(TrimmedNonEmptyString), + observedAtMs: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), + state: Schema.Literals(["active", "inactive", "unknown"]), + reasonCode: Schema.Literals([ + "goal_null", + "goal_present", + "no_session", + "session_stopped", + "instance_mismatch", + "native_cursor_missing", + "unsupported", + "timeout", + "malformed", + "goal_field_omitted", + "rpc_error", + "context_changed", + ]), +}); +export type ProviderGoalStateObservation = typeof ProviderGoalStateObservation.Type; + +export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") + .add( + HttpApiEndpoint.get( + "commandObservation", + "/api/orchestration/threads/:threadId/commands/:commandId", + { + headers: OptionalBearerHeaders, + params: Schema.Struct({ threadId: ThreadId, commandId: CommandId }), + query: { messageId: MessageId }, + success: OrchestrationCommandObservation, + error: [...EnvironmentOrchestrationThreadSnapshotErrors, EnvironmentRequestInvalidError], + }, + ).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get( + "providerGoalState", + "/api/orchestration/threads/:threadId/provider-goal-state", + { + headers: OptionalBearerHeaders, + params: EnvironmentOrchestrationThreadSnapshotParams, + query: { expectedInstanceId: ProviderInstanceId }, + success: ProviderGoalStateObservation, + error: EnvironmentOrchestrationThreadSnapshotErrors, + }, + ).middleware(EnvironmentAuthenticatedAuth), + ) + .add( HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", { headers: OrchestrationProtocolHeaders, @@ -651,10 +799,162 @@ class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") }), ) {} +export class EnvironmentQueueDispatchHttpApi extends HttpApiGroup.make("queueDispatch").add( + HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", { + headers: OptionalBearerHeaders, + payload: Schema.Unknown, + success: Schema.Struct({ + sequence: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), + }), + error: [...EnvironmentOrchestrationThreadSnapshotErrors, EnvironmentRequestInvalidError], + }).middleware(EnvironmentAuthenticatedAuth), +) {} + +export class ProviderQueueHttpApi extends HttpApiGroup.make("providerQueue") + .add( + HttpApiEndpoint.get("inventory", "/api/provider-queue/inventory", { + headers: OptionalBearerHeaders, + success: ProviderQueueInventory, + error: [EnvironmentScopeRequiredError], + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("usage", "/api/provider-queue/instances/:instanceId/usage", { + headers: OptionalBearerHeaders, + params: Schema.Struct({ instanceId: ProviderInstanceId }), + success: ProviderQueueRefreshResult, + error: [EnvironmentScopeRequiredError], + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("refresh", "/api/provider-queue/instances/:instanceId/refresh", { + headers: OptionalBearerHeaders, + params: Schema.Struct({ instanceId: ProviderInstanceId }), + success: ProviderQueueRefreshResult, + error: [EnvironmentScopeRequiredError], + }).middleware(EnvironmentAuthenticatedAuth), + ) {} + +export class EnvironmentConversationLibraryHttpApi extends HttpApiGroup.make( + "conversationLibrary", +).add( + HttpApiEndpoint.post("conversationLibrary", CONVERSATION_LIBRARY_PATH, { + headers: OptionalBearerHeaders, + payload: LibraryRequestSchema, + success: LibraryReplySchema, + error: [...EnvironmentConversationLibraryErrorSchemas, EnvironmentScopeRequiredError], + }).middleware(EnvironmentAuthenticatedAuth), +) {} +const VoiceReviewParams = Schema.Struct({ + id: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), +}); +const VoiceReviewHeaders = OptionalBearerHeaders; +class EnvironmentVoiceReviewHttpApi extends HttpApiGroup.make("voiceReview") + .add( + HttpApiEndpoint.get("list", "/api/voice-review/drafts", { + headers: VoiceReviewHeaders, + query: { + scope: Schema.optional(Schema.Literals(["pending", "recent"])), + limit: Schema.optional( + Schema.FiniteFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: 200 }), + ), + ), + }, + success: VoiceReviewDraftList, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("get", "/api/voice-review/drafts/:id", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + success: VoiceReviewDraft, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("pause", "/api/voice-review/drafts/:id/pause", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("play", "/api/voice-review/drafts/:id/play", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("editBegin", "/api/voice-review/drafts/:id/edit-begin", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("editSave", "/api/voice-review/drafts/:id/edit-save", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewEditSavePayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("editCancel", "/api/voice-review/drafts/:id/edit-cancel", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewEditCancelPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("sendNow", "/api/voice-review/drafts/:id/send-now", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("delete", "/api/voice-review/drafts/:id/delete", { + headers: VoiceReviewHeaders, + params: VoiceReviewParams, + payload: VoiceReviewRevisionPayload, + success: VoiceReviewMutationResult, + error: VoiceReviewErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) {} + +class EnvironmentHostStatusHttpApi extends HttpApiGroup.make("hostStatus").add( + HttpApiEndpoint.get("snapshot", "/api/host-status", { + headers: OptionalBearerHeaders, + success: HostStatusSnapshot, + error: [EnvironmentScopeRequiredError, EnvironmentInternalError], + }).middleware(EnvironmentAuthenticatedAuth), +) {} + export class EnvironmentHttpApi extends HttpApi.make("environment") + .add(ProviderQueueHttpApi) + .add(EnvironmentQueueDispatchHttpApi) + .add(EnvironmentVoiceReviewHttpApi) + .add(EnvironmentHostStatusHttpApi) .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) .add(EnvironmentPullRequestsHttpApi) .add(EnvironmentProjectsHttpApi) - .add(EnvironmentConnectHttpApi) {} + .add(EnvironmentConnectHttpApi) + .add(EnvironmentConversationLibraryHttpApi) {} diff --git a/packages/contracts/src/hostStatus.ts b/packages/contracts/src/hostStatus.ts new file mode 100644 index 000000000..2ec925a27 --- /dev/null +++ b/packages/contracts/src/hostStatus.ts @@ -0,0 +1,37 @@ +import * as Schema from "effect/Schema"; +import { NonNegativeInt, PositiveInt, IsoDateTime } from "./baseSchemas.ts"; + +export const HostStatusId = Schema.Literals(["vps", "test", "mini", "home"]); +export type HostStatusId = typeof HostStatusId.Type; + +export const HostStatus = Schema.Union([ + Schema.Struct({ + id: HostStatusId, + status: Schema.Literal("available"), + cpuUsagePercent: Schema.Number.check( + Schema.isFinite(), + Schema.isGreaterThanOrEqualTo(0), + Schema.isLessThanOrEqualTo(100), + ), + logicalCpuCount: PositiveInt, + // Occupied is total minus free, including reclaimable memory. + occupiedMemoryBytes: NonNegativeInt, + availableMemoryBytes: Schema.optional(NonNegativeInt), + totalMemoryBytes: PositiveInt, + sampledAt: IsoDateTime, + }), + Schema.Struct({ + id: HostStatusId, + status: Schema.Literal("unavailable"), + reason: Schema.Literals([ + "not_configured", + "upstream_unavailable", + "invalid_response", + "stale", + ]), + }), +]); +export type HostStatus = typeof HostStatus.Type; + +export const HostStatusSnapshot = Schema.Struct({ hosts: Schema.Array(HostStatus) }); +export type HostStatusSnapshot = typeof HostStatusSnapshot.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8690bb1b2..dcd9c6987 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from "./acpRegistry.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; +export * from "./voiceReview.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; export * from "./desktopAppActivation.ts"; @@ -17,6 +18,7 @@ export * from "./provider.ts"; export * from "./providerInstance.ts"; export * from "./providerSetup.ts"; export * from "./providerRuntime.ts"; +export * from "./providerRuntimeIdentity.ts"; export * from "./providerUsageLimits.ts"; export * from "./usageLimitSourceId.ts"; export * from "./providerPolicy.ts"; @@ -33,6 +35,7 @@ export * from "./sourceControl.ts"; export * from "./projectClone.ts"; export * from "./pullRequest.ts"; export * from "./orchestrationDispatch.ts"; +export * from "./nativeCreation.ts"; export * from "./orchestrationProject.ts"; export * from "./orchestrationV2.ts"; export * from "./applicationEvent.ts"; @@ -55,8 +58,14 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; +export * from "./tokenAccounting.ts"; export * from "./scheduledTask.ts"; export * from "./worktreeMcp.ts"; export * from "./resourceTelemetry.ts"; export * from "./rpc.ts"; export * from "./worktreeSetup.ts"; + +export * from "./providerQueue.ts"; + +export * from "./queueDispatch.ts"; +export * from "./hostStatus.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index db2cc2a36..031e1d790 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -4,6 +4,7 @@ import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, PreviewAutomationPressInput, + PreviewAutomationRuntimeIdentity, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, @@ -1123,6 +1124,7 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + getPreviewAutomationRuntimeIdentity?: () => Promise; /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ getPathForFile?: (file: File) => string; /** The desktop client's OS platform, read from Electron's preload process. */ diff --git a/packages/contracts/src/nativeCreation.test.ts b/packages/contracts/src/nativeCreation.test.ts new file mode 100644 index 000000000..3480ff38e --- /dev/null +++ b/packages/contracts/src/nativeCreation.test.ts @@ -0,0 +1,408 @@ +import { assert, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { + ExecutionEnvironmentCapabilities, + NativeBootstrapCreationCapability, +} from "./environment.ts"; +import { + NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES, + NATIVE_BOOTSTRAP_MAX_SUBMISSION_BYTES, + NativeBootstrapSubmission, + NativeCreationEffect, + NativeCreationGuard, + NativeCreationObservation, +} from "./nativeCreation.ts"; +import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts"; +import { + OrchestrationV2ThreadLaunchResult, + OrchestrationV2DispatchCommandError, +} from "./orchestrationV2.ts"; + +const guard = { + schema: "t3.native-creation-guard/v1", + grantId: "grant-1", + grantRevision: 1, +}; +const submission = { + schema: "t3.native-bootstrap-submission/v1", + preparationBase64: "e30=", + creationGuard: guard, +}; +const capability = { + submissionSchema: "t3.native-bootstrap-submission/v1", + preparationSchema: "voice.t3-bootstrap-preparation/v1", + observationSchema: "t3.native-creation-observation/v1", + guardRequired: true, +}; + +const digest = "a".repeat(64); +const creation = { + schema: "t3.native-creation-observation/v1", + preparationId: "preparation-1", + operationId: "operation-1", + preparationSha256: digest, + bindingDigest: digest, + promptDigest: digest, + commandDigest: digest, + normalizedCommandDigest: digest, + claimId: "claim-1", + claimedBootId: "boot-1", + claimedAt: "2026-10-02T12:00:00Z", + actorSessionId: "session-1", + grantId: "grant-1", + grantRevision: 1, + binding: { + backendInstance: "backend-1", + environmentId: "environment-1", + projectId: "project-1", + projectCwd: "/workspace/project", + accountRef: "account-ref-1", + accountBindingId: "account-binding-1", + accountBindingRevision: 1, + providerModelSelection: { + instanceId: "codex", + model: "model-1", + options: [ + { id: "fast", value: true }, + { id: "effort", value: "high" }, + ], + }, + runtimeMode: "full-access", + interactionMode: "default", + baseBranch: "main", + startFromOrigin: true, + runSetupScript: false, + requestedBranch: "t3code/voice-branch", + }, + incarnation: null, + effects: [], + unresolvedEffects: [], + outcome: "unknown", +}; +const acceptsWire = >(schema: S) => { + const decode = Schema.decodeUnknownOption(schema); + return (input: unknown) => Option.isSome(decode(input)); +}; + +const effect = { effectId: "effect-1", ordinal: 0, timestamp: creation.claimedAt }; +const decodeSubmission = Schema.decodeUnknownSync(NativeBootstrapSubmission); +const encodeSubmission = Schema.encodeSync(NativeBootstrapSubmission); +const decodeCreation = Schema.decodeUnknownSync(NativeCreationObservation); +const encodeCreation = Schema.encodeSync(NativeCreationObservation); + +it("requires a closed guarded submission and a positive safe grant revision", () => { + const accepts = acceptsWire(NativeBootstrapSubmission); + assert.isTrue(accepts(submission)); + const decoded = decodeSubmission(submission); + assert.deepEqual(encodeSubmission(decoded), submission); + assert.isFalse(accepts({ ...submission, creationGuard: undefined })); + assert.isFalse(accepts({ ...submission, extra: true })); + assert.isFalse(accepts({ ...submission, creationGuard: { ...guard, extra: true } })); + for (const grantRevision of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Infinity, NaN]) { + assert.isFalse(acceptsWire(NativeCreationGuard)({ ...guard, grantRevision })); + } + assert.isTrue( + acceptsWire(NativeCreationGuard)({ ...guard, grantRevision: Number.MAX_SAFE_INTEGER }), + ); +}); + +it("bounds decoded preparation bytes, rejects malformed base64 and bounds submission UTF-8", () => { + const accepts = acceptsWire(NativeBootstrapSubmission); + const atLimit = btoa("\0".repeat(NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES)); + assert.isTrue(accepts({ ...submission, preparationBase64: atLimit })); + assert.isFalse( + accepts({ + ...submission, + preparationBase64: btoa("\0".repeat(NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES + 1)), + }), + ); + for (const preparationBase64 of ["", "e30", "e30=\n", "-___", "====", "A==="]) { + assert.isFalse(accepts({ ...submission, preparationBase64 })); + } + assert.isFalse( + accepts({ + ...submission, + creationGuard: { ...guard, grantId: "é".repeat(NATIVE_BOOTSTRAP_MAX_SUBMISSION_BYTES / 2) }, + }), + ); +}); + +it("keeps legacy capabilities and observations decodable while requiring exact creation versions", () => { + const decodeCapabilities = Schema.decodeUnknownSync(ExecutionEnvironmentCapabilities); + assert.deepEqual(decodeCapabilities({}), { repositoryIdentity: false }); + assert.deepEqual( + decodeCapabilities({ nativeBootstrapCreation: capability }).nativeBootstrapCreation, + capability, + ); + const acceptsCapability = acceptsWire(NativeBootstrapCreationCapability); + assert.isFalse(acceptsCapability({ ...capability, extra: true })); + assert.isFalse(acceptsCapability({ ...capability, guardRequired: false })); + for (const field of ["submissionSchema", "preparationSchema", "observationSchema"]) { + assert.isFalse(acceptsCapability({ ...capability, [field]: "unsupported/v2" })); + } + const decodeCreationField = Schema.decodeUnknownSync( + Schema.Struct({ creation: OrchestrationV2ThreadLaunchResult.fields.creation }), + ); + assert.deepEqual(decodeCreationField({}), {}); + assert.deepEqual(decodeCreationField({ creation }).creation, creation); +}); + +it("records historical unknown effects without prompt bytes or legacy provider normalization", () => { + const accepts = acceptsWire(NativeCreationObservation); + const started = { + ...effect, + kind: "fetch", + phase: "started", + projectCwd: "/workspace/project", + baseRef: "origin/main", + }; + assert.isTrue(accepts({ ...creation, effects: [started], unresolvedEffects: [effect.effectId] })); + assert.isFalse(accepts({ ...creation, prompt: "private prompt" })); + assert.isFalse(accepts({ ...creation, preparationBase64: submission.preparationBase64 })); + assert.isFalse( + accepts({ + ...creation, + binding: { + ...creation.binding, + providerModelSelection: { provider: "codex", model: "model-1" }, + }, + }), + ); + assert.isFalse( + accepts({ + ...creation, + binding: { + ...creation.binding, + providerModelSelection: { instanceId: "codex", model: "model-1", options: { fast: true } }, + }, + }), + ); + assert.isFalse(accepts({ ...creation, incarnation: { eventId: "event-1" } })); + assert.isFalse(accepts({ ...creation, preparationSha256: "not-a-digest" })); + for (const binding of [ + { ...creation.binding, extra: true }, + { + ...creation.binding, + providerModelSelection: { ...creation.binding.providerModelSelection, extra: true }, + }, + { + ...creation.binding, + providerModelSelection: { + ...creation.binding.providerModelSelection, + options: [{ id: "fast", value: true, extra: true }], + }, + }, + ]) { + assert.isFalse(accepts({ ...creation, binding })); + } + assert.isFalse( + accepts({ ...creation, incarnation: { eventId: "event-1", sequence: 1, extra: true } }), + ); + const decoded = decodeCreation(creation); + assert.deepEqual(encodeCreation(decoded), creation); +}); + +it("requires stage-specific effect facts and preserves failed and unknown external results", () => { + const accepts = acceptsWire(NativeCreationEffect); + const fetch = { + ...effect, + kind: "fetch", + projectCwd: "/workspace/project", + baseRef: "origin/main", + }; + assert.isFalse(accepts({ ...fetch, phase: "completed" })); + for (const result of ["succeeded", "failed", "unknown"]) { + assert.isTrue(accepts({ ...fetch, phase: "completed", result })); + } + assert.isFalse(accepts({ ...fetch, phase: "started", details: {} })); + assert.isFalse(accepts({ ...effect, kind: "arbitrary", phase: "started" })); + for (const commandType of [ + "thread.create", + "thread.message.user.append", + "thread.session.set", + "thread.meta.update", + "thread.turn.start", + "thread.delete", + ]) { + const command = { + ...effect, + kind: "native_command", + commandId: "command-1", + threadId: "thread-1", + commandType, + commandDigest: digest, + }; + assert.isTrue(accepts({ ...command, phase: "started" })); + assert.isFalse(accepts({ ...command, phase: "completed" })); + assert.isTrue(accepts({ ...command, phase: "completed", eventId: "event-1", sequence: 1 })); + } + const worktree = { + projectCwd: "/workspace/project", + worktreePath: "/native/worktrees/project/voice", + branch: "t3code/voice-branch", + baseRef: "origin/main", + ownership: "claimed", + }; + assert.isTrue(accepts({ ...effect, kind: "worktree", phase: "started", ...worktree })); + assert.isTrue( + accepts({ ...effect, kind: "worktree", phase: "completed", ...worktree, result: "unknown" }), + ); + assert.isTrue( + accepts({ + ...effect, + kind: "setup", + phase: "started", + worktreePath: worktree.worktreePath, + terminalId: null, + }), + ); + assert.isTrue( + accepts({ + ...effect, + kind: "setup", + phase: "completed", + worktreePath: worktree.worktreePath, + terminalId: "terminal-1", + exitCode: 1, + result: "failed", + }), + ); + assert.isTrue( + accepts({ + ...effect, + kind: "cleanup", + phase: "completed", + resource: { + kind: "thread", + threadId: "thread-1", + incarnation: { eventId: "created-event-1", sequence: 1 }, + }, + recoveryScopeId: "recovery-1", + result: "unknown", + }), + ); + for (const action of [ + "tracker_registration", + "bootstrap_detachment", + "setup_detachment", + "setup_completion_detachment", + ]) { + assert.isTrue( + accepts({ ...effect, kind: "lifecycle", phase: "started", threadId: "thread-1", action }), + ); + } + assert.isFalse( + accepts({ + ...effect, + kind: "cleanup", + phase: "started", + resource: { kind: "thread", threadId: "thread-1" }, + recoveryScopeId: "recovery-1", + }), + ); + assert.isFalse(accepts({ ...effect, kind: "setup", phase: "completed", result: "failed" })); +}); + +it("keeps dispatch rejection codes optional and closed", () => { + const decode = Schema.decodeUnknownSync(OrchestrationDispatchCommandError); + const error = { _tag: "OrchestrationDispatchCommandError", message: "Rejected" }; + assert.equal(decode(error).creationRejectionCode, undefined); + assert.equal( + decode({ ...error, creationRejectionCode: "stale_grant" }).creationRejectionCode, + "stale_grant", + ); + assert.throws(() => decode({ ...error, creationRejectionCode: "invented" })); +}); + +it("keeps V2 dispatch rejection codes optional without altering current error fields", () => { + const decode = Schema.decodeUnknownSync(OrchestrationV2DispatchCommandError); + const error = { + _tag: "OrchestrationV2DispatchCommandError", + commandId: "command-1", + commandType: "message.dispatch", + message: "Rejected", + }; + assert.equal(decode(error).creationRejectionCode, undefined); + assert.equal( + decode({ ...error, creationRejectionCode: "stale_grant" }).creationRejectionCode, + "stale_grant", + ); + assert.throws(() => decode({ ...error, creationRejectionCode: "invented" })); +}); + +it("does not reinterpret V2 execution commands as historical native command facts", () => { + const accepts = acceptsWire(NativeCreationEffect); + assert.isFalse( + accepts({ + ...effect, + kind: "native_command", + phase: "started", + commandId: "command-1", + threadId: "thread-1", + commandType: "message.dispatch", + commandDigest: digest, + }), + ); +}); + +it("round-trips current launch results in both upgrade directions without changing historical bytes", () => { + const now = DateTime.makeUnsafe("2026-10-02T12:00:00Z"); + const projection = { + thread: { + createdBy: "user", + creationSource: "server", + id: "thread-1", + projectId: "project-1", + title: "Synthetic thread", + providerInstanceId: "codex", + modelSelection: { instanceId: "codex", model: "fixture-model" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: now, + }; + const beforeCreation = Schema.Struct({ + threadId: OrchestrationV2ThreadLaunchResult.fields.threadId, + projection: OrchestrationV2ThreadLaunchResult.fields.projection, + resumed: OrchestrationV2ThreadLaunchResult.fields.resumed, + }); + const decode = Schema.decodeUnknownSync(OrchestrationV2ThreadLaunchResult); + const legacy = { threadId: "thread-1", projection, resumed: false }; + const acceptedLegacy = decode(legacy); + assert.equal(acceptedLegacy.creation, undefined); + assert.deepEqual(acceptedLegacy, Schema.decodeUnknownSync(beforeCreation)(legacy)); + const accepted = decode({ ...legacy, creation }); + assert.deepEqual(encodeCreation(accepted.creation!), creation); + assert.deepEqual( + Schema.decodeUnknownSync(beforeCreation)({ ...legacy, creation }), + acceptedLegacy, + ); + assert.throws(() => decode({ ...legacy, creation: { ...creation, extra: true } })); +}); diff --git a/packages/contracts/src/nativeCreation.ts b/packages/contracts/src/nativeCreation.ts new file mode 100644 index 000000000..adcd4fca1 --- /dev/null +++ b/packages/contracts/src/nativeCreation.ts @@ -0,0 +1,281 @@ +import * as Schema from "effect/Schema"; + +import { + CommandId, + EventId, + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, +} from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; +import { RuntimeMode, ProviderInteractionMode } from "./providerPolicy.ts"; + +const nativeCreationStruct = (fields: Fields) => { + const schema = Schema.Struct(fields); + // Flipped checks validate original wire keys that ordinary struct decoding would strip. + return Schema.flip( + Schema.flip(schema).check( + Schema.makeFilter((value) => + Reflect.ownKeys(value).every((key) => Object.hasOwn(fields, key)), + ), + ), + ); +}; + +export const NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES = 1_048_576; +export const NATIVE_BOOTSTRAP_MAX_SUBMISSION_BYTES = 2_097_152; + +const NativeCreationRevision = Schema.Int.check( + Schema.isBetween({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), +); +const NativeCreationString = Schema.String.check(Schema.isNonEmpty()); +const NativeCreationSha256 = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); + +export const NativeCreationGuard = nativeCreationStruct({ + schema: Schema.Literal("t3.native-creation-guard/v1"), + grantId: NativeCreationString, + grantRevision: NativeCreationRevision, +}); +export type NativeCreationGuard = typeof NativeCreationGuard.Type; + +export const NativeBootstrapSubmission = nativeCreationStruct({ + schema: Schema.Literal("t3.native-bootstrap-submission/v1"), + preparationBase64: Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(4 * Math.ceil(NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES / 3)), + Schema.isPattern(/^[A-Za-z0-9+/]*={0,2}$/), + Schema.makeFilter((value) => { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return ( + value.length % 4 === 0 && + (value.length / 4) * 3 - padding <= NATIVE_BOOTSTRAP_MAX_PREPARATION_BYTES + ); + }), + ), + creationGuard: NativeCreationGuard, +}).check( + Schema.makeFilter( + (value) => + new TextEncoder().encode(JSON.stringify(value)).byteLength <= + NATIVE_BOOTSTRAP_MAX_SUBMISSION_BYTES, + ), +); +export type NativeBootstrapSubmission = typeof NativeBootstrapSubmission.Type; + +export const NativeCreationRejectionCode = Schema.Literals([ + "unsupported_authority", + "invalid_preparation", + "stale_grant", + "binding_mismatch", + "conflict", + "unresolved_claim", +]); +export type NativeCreationRejectionCode = typeof NativeCreationRejectionCode.Type; + +const NativeCreationModelSelection = nativeCreationStruct({ + instanceId: ProviderInstanceId, + model: NativeCreationString, + options: Schema.optionalKey( + Schema.Array( + nativeCreationStruct({ + id: NativeCreationString, + value: Schema.Union([NativeCreationString, Schema.Boolean]), + }), + ), + ), +}); + +export const NativeCreationHistoricalBinding = nativeCreationStruct({ + backendInstance: NativeCreationString, + environmentId: NativeCreationString, + projectId: ProjectId, + projectCwd: NativeCreationString, + accountRef: NativeCreationString, + accountBindingId: NativeCreationString, + accountBindingRevision: NativeCreationRevision, + providerModelSelection: NativeCreationModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + baseBranch: NativeCreationString, + startFromOrigin: Schema.Boolean, + runSetupScript: Schema.Boolean, + requestedBranch: NativeCreationString, +}); +export type NativeCreationHistoricalBinding = typeof NativeCreationHistoricalBinding.Type; + +const NativeCreationIncarnation = nativeCreationStruct({ + eventId: EventId, + sequence: NonNegativeInt, +}); +const NativeCreationEffectBase = { + effectId: NativeCreationString, + ordinal: NonNegativeInt, + timestamp: IsoDateTime, +}; +const NativeCreationCommandDetails = { + commandId: CommandId, + threadId: ThreadId, + commandType: Schema.Literals([ + "thread.create", + "thread.meta.update", + "thread.message.user.append", + "thread.session.set", + "thread.turn.start", + "thread.delete", + ]), + commandDigest: NativeCreationSha256, +}; +const NativeCreationWorktreeDetails = { + projectCwd: NativeCreationString, + worktreePath: NativeCreationString, + branch: NativeCreationString, + baseRef: NativeCreationString, + ownership: Schema.Literals(["claimed", "created", "unknown"]), +}; +const NativeCreationCleanupDetails = { + resource: Schema.Union([ + nativeCreationStruct({ + kind: Schema.Literal("worktree"), + ...NativeCreationWorktreeDetails, + }), + nativeCreationStruct({ + kind: Schema.Literal("setup_terminal"), + terminalId: NativeCreationString, + worktreePath: NativeCreationString, + }), + nativeCreationStruct({ + kind: Schema.Literal("thread"), + threadId: ThreadId, + incarnation: NativeCreationIncarnation, + }), + ]), + recoveryScopeId: NativeCreationString, +}; +const NativeCreationExternalResult = Schema.Literals(["succeeded", "failed", "unknown"]); + +const NativeCreationLifecycleDetails = { + threadId: ThreadId, + action: Schema.Literals([ + "normalization", + "tracker_registration", + "bootstrap_detachment", + "setup_detachment", + "setup_completion_detachment", + "worktree_ownership", + "deletion_drain", + "git_status_refresh", + ]), +}; + +export const NativeCreationEffect = Schema.Union([ + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("lifecycle"), + phase: Schema.Literal("started"), + ...NativeCreationLifecycleDetails, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("lifecycle"), + phase: Schema.Literal("completed"), + ...NativeCreationLifecycleDetails, + result: NativeCreationExternalResult, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("native_command"), + phase: Schema.Literal("started"), + ...NativeCreationCommandDetails, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("native_command"), + phase: Schema.Literal("completed"), + ...NativeCreationCommandDetails, + eventId: EventId, + sequence: NonNegativeInt, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("fetch"), + phase: Schema.Literal("started"), + projectCwd: NativeCreationString, + baseRef: NativeCreationString, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("fetch"), + phase: Schema.Literal("completed"), + projectCwd: NativeCreationString, + baseRef: NativeCreationString, + result: NativeCreationExternalResult, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("worktree"), + phase: Schema.Literal("started"), + ...NativeCreationWorktreeDetails, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("worktree"), + phase: Schema.Literal("completed"), + ...NativeCreationWorktreeDetails, + result: NativeCreationExternalResult, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("setup"), + phase: Schema.Literal("started"), + worktreePath: NativeCreationString, + terminalId: Schema.NullOr(NativeCreationString), + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("setup"), + phase: Schema.Literal("completed"), + worktreePath: NativeCreationString, + terminalId: Schema.NullOr(NativeCreationString), + exitCode: Schema.NullOr(Schema.Int), + result: NativeCreationExternalResult, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("cleanup"), + phase: Schema.Literal("started"), + ...NativeCreationCleanupDetails, + }), + nativeCreationStruct({ + ...NativeCreationEffectBase, + kind: Schema.Literal("cleanup"), + phase: Schema.Literal("completed"), + ...NativeCreationCleanupDetails, + result: NativeCreationExternalResult, + }), +]); +export type NativeCreationEffect = typeof NativeCreationEffect.Type; + +/** Historical creation attestation does not attest a terminal turn or release capacity. */ +export const NativeCreationObservation = nativeCreationStruct({ + schema: Schema.Literal("t3.native-creation-observation/v1"), + preparationId: NativeCreationString, + operationId: NativeCreationString, + preparationSha256: NativeCreationSha256, + bindingDigest: NativeCreationSha256, + promptDigest: NativeCreationSha256, + commandDigest: NativeCreationSha256, + normalizedCommandDigest: NativeCreationSha256, + claimId: NativeCreationString, + claimedBootId: NativeCreationString, + claimedAt: IsoDateTime, + actorSessionId: NativeCreationString, + grantId: NativeCreationString, + grantRevision: NativeCreationRevision, + binding: NativeCreationHistoricalBinding, + incarnation: Schema.NullOr(NativeCreationIncarnation), + effects: Schema.Array(NativeCreationEffect), + unresolvedEffects: Schema.Array(NativeCreationString), + outcome: Schema.Literals(["complete", "in_progress", "incomplete", "unknown"]), +}); +export type NativeCreationObservation = typeof NativeCreationObservation.Type; diff --git a/packages/contracts/src/orchestrationDispatch.ts b/packages/contracts/src/orchestrationDispatch.ts index 0dec60e48..4d12ff0b0 100644 --- a/packages/contracts/src/orchestrationDispatch.ts +++ b/packages/contracts/src/orchestrationDispatch.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; +import { NativeCreationRejectionCode } from "./nativeCreation.ts"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; /** @@ -11,6 +12,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedError { + it("keeps legacy receiving correlation out of public thread and run schemas", () => { + expect(Object.keys(OrchestrationV2AppThread.fields)).not.toContain("legacyBootstrapClaim"); + for (const privateField of [ + "legacyBootstrap", + "legacyPreparationFailureKnown", + "legacyPreparation", + "legacyReleaseDecision", + "workspaceRunSetupScript", + ]) { + expect(Object.keys(OrchestrationV2Run.fields)).not.toContain(privateField); + } + expect(OrchestrationV2Run.fields.workspacePreparation).toBeDefined(); + }); + it("carries command failure metadata through runtime and JSON schemas without output text", () => { const base = { id: "command-item", @@ -971,6 +989,32 @@ describe("orchestration V2 contracts", () => { expect(providerThread.pendingBackgroundTasks).toEqual([]); expect(providerThread.contextUsage).toBeNull(); expect(providerThread.nativeMetadata).toBeNull(); + expect(providerThread.runtimeIdentity).toBeUndefined(); + const identity = { + runtimeGeneration: "native-query-7", + evidenceRevision: 3, + requested: { + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerDriver: ProviderDriverKind.make("claudeAgent"), + model: "requested", + serviceTier: null, + }, + observed: { + backend: { status: "unavailable" as const, reason: "Not reported." }, + model: { + status: "observed" as const, + value: "native-model", + sourceEvent: "claude.system:init", + }, + account: { status: "unavailable" as const, reason: "Not bound." }, + serviceTier: { status: "unavailable" as const, reason: "Not reported." }, + }, + }; + expect( + decodeOrchestrationV2ProviderThreadJson( + encodeOrchestrationV2ProviderThreadJson({ ...providerThread, runtimeIdentity: identity }), + ).runtimeIdentity, + ).toEqual(identity); const runtimeThread = decodeOrchestrationV2ProviderThread({ id: "provider-thread-2", @@ -992,6 +1036,7 @@ describe("orchestration V2 contracts", () => { expect(runtimeThread.pendingBackgroundTasks).toEqual([]); expect(runtimeThread.contextUsage).toBeNull(); expect(runtimeThread.nativeMetadata).toBeNull(); + expect(runtimeThread.runtimeIdentity).toBeUndefined(); }); it("decodes historical thread shell JSON without pendingBackgroundTasks as empty roster", () => { @@ -1035,6 +1080,11 @@ describe("orchestration V2 contracts", () => { }); expect(shell.pendingBackgroundTasks).toEqual([]); + expect(shell.threadMessagesBlocked ?? false).toBe(false); + expect( + decodeOrchestrationV2ThreadShell({ ...shell, threadMessagesBlocked: true }) + .threadMessagesBlocked, + ).toBe(true); }); }); @@ -1248,3 +1298,46 @@ describe("limit recovery choice updates", () => { expect(decode({ ...identity, ...choice })).toEqual({ ...identity, ...choice }); }); }); + +describe("queued tool delivery command compatibility", () => { + it("preserves absent, false and true eligibility without a decode default", () => { + const command = { + type: "message.dispatch", + commandId: "queue-compat", + threadId: "thread", + messageId: "message", + createdBy: "user", + creationSource: "web", + text: "Queue", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }; + expect(decodeOrchestrationV2Command(command)).not.toHaveProperty("queuedToolBoundaryEligible"); + for (const value of [false, true]) + expect( + decodeOrchestrationV2Command({ ...command, queuedToolBoundaryEligible: value }), + ).toHaveProperty("queuedToolBoundaryEligible", value); + expect(() => + decodeOrchestrationV2Command({ ...command, queuedToolBoundaryEligible: 1 }), + ).toThrow(); + }); +}); + +describe("worktree launch base", () => { + const decode = Schema.decodeUnknownSync(OrchestrationV2ThreadLaunchWorkspaceStrategy); + + it("round-trips an omitted base for automatic server selection", () => { + const input = { type: "worktree", branch: "feature", startFromOrigin: true }; + expect(Schema.encodeSync(OrchestrationV2ThreadLaunchWorkspaceStrategy)(decode(input))).toEqual( + input, + ); + }); + + it("preserves explicit bases and rejects blank bases", () => { + expect(decode({ type: "worktree", baseRef: "release/stable" })).toEqual({ + type: "worktree", + baseRef: "release/stable", + }); + expect(() => decode({ type: "worktree", baseRef: " " })).toThrow(); + }); +}); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 220fec2b1..e4f7037e6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1,3 +1,6 @@ +import { ThreadTurnDispatchGuard } from "./providerQueue.ts"; +import { RuntimeIdentityAttestation } from "./providerRuntimeIdentity.ts"; +import { NativeCreationObservation, NativeCreationRejectionCode } from "./nativeCreation.ts"; import { OrchestrationMessageContext } from "./composerContext.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -354,6 +357,22 @@ export const OrchestrationV2LimitRecoveryUpdate = Schema.Struct({ ); export type OrchestrationV2LimitRecoveryUpdate = typeof OrchestrationV2LimitRecoveryUpdate.Type; +/** Receiving-only correlation persisted with legacy queue preparation. */ +export const OrchestrationV2LegacyBootstrapPolicy = Schema.Struct({ + version: Schema.Literal(1), + createCommandId: CommandId, + birthCommandId: CommandId, + releaseCommandId: CommandId, + projectId: ProjectId, + threadId: ThreadId, + messageId: MessageId, + payloadHash: TrimmedNonEmptyString, + ownsNewThread: Schema.Boolean, + runId: Schema.optional(RunId), + dispatchGuard: Schema.optional(ThreadTurnDispatchGuard), +}); +export type OrchestrationV2LegacyBootstrapPolicy = typeof OrchestrationV2LegacyBootstrapPolicy.Type; + export const OrchestrationV2AppThread = Schema.Struct({ ...OrchestrationV2CreationFields, id: ThreadId, @@ -363,6 +382,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.optional(Schema.Boolean), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), /** Pull request the user linked to this thread (#8160); optional so @@ -388,6 +408,17 @@ export const OrchestrationV2AppThread = Schema.Struct({ createdAt: Schema.DateTimeUtc, updatedAt: Schema.DateTimeUtc, archivedAt: Schema.NullOr(Schema.DateTimeUtc), + selfSettlement: Schema.optional( + Schema.NullOr( + Schema.Struct({ + mcpCredentialId: TrimmedNonEmptyString, + commandId: CommandId, + runId: RunId, + providerSessionId: ProviderSessionId, + providerInstanceId: ProviderInstanceId, + }), + ), + ), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), @@ -520,7 +551,7 @@ export const OrchestrationV2ThreadLaunchWorkspaceStrategy = Schema.Union([ }), Schema.Struct({ type: Schema.Literal("worktree"), - baseRef: TrimmedNonEmptyString, + baseRef: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(TrimmedNonEmptyString), startFromOrigin: Schema.optional(Schema.Boolean), }), @@ -545,6 +576,8 @@ export const OrchestrationV2Run = Schema.Struct({ queuePosition: Schema.optional(Schema.NullOr(PositiveInt)), /** Restart recovery holds the queue until the user explicitly resumes it. */ queueHeld: Schema.optional(Schema.Boolean), + /** Recorded queue admission; absent or false keeps separate-turn delivery. */ + queuedToolBoundaryEligible: Schema.optional(Schema.Boolean), requestedAt: Schema.DateTimeUtc, startedAt: Schema.NullOr(Schema.DateTimeUtc), completedAt: Schema.NullOr(Schema.DateTimeUtc), @@ -833,6 +866,7 @@ export type OrchestrationV2ProviderThreadNativeMetadata = typeof OrchestrationV2ProviderThreadNativeMetadata.Type; export const OrchestrationV2ProviderThread = Schema.Struct({ + runtimeIdentity: Schema.optional(RuntimeIdentityAttestation), id: ProviderThreadId, driver: ProviderDriverKind, providerInstanceId: ProviderInstanceId, @@ -1666,6 +1700,40 @@ export const OrchestrationV2DomainEvent = Schema.Union([ ]); export type OrchestrationV2DomainEvent = typeof OrchestrationV2DomainEvent.Type; +export const OrchestrationV2LegacyPreflightBinding = Schema.Struct({ + policy: OrchestrationV2LegacyBootstrapPolicy, + canonicalPayload: TrimmedNonEmptyString, + fetch: Schema.Struct({ + cwd: TrimmedNonEmptyString, + baseRef: TrimmedNonEmptyString, + startFromOrigin: Schema.Boolean, + requireWorktree: Schema.Boolean, + remote: Schema.NullOr(TrimmedNonEmptyString), + }), +}); +export type OrchestrationV2LegacyPreflightBinding = + typeof OrchestrationV2LegacyPreflightBinding.Type; + +export const OrchestrationV2PrivateEvent = Schema.Union([ + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("legacy-bootstrap.preflight-intent"), + payload: OrchestrationV2LegacyPreflightBinding, + }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("legacy-bootstrap.preflight-outcome"), + payload: Schema.Struct({ + binding: OrchestrationV2LegacyPreflightBinding, + intentCommandId: CommandId, + intentSequence: NonNegativeInt, + status: Schema.Literals(["ready", "known_failed", "unknown"]), + detail: Schema.optional(Schema.String), + workspaceStrategy: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), + }), + }), +]); +export type OrchestrationV2PrivateEvent = typeof OrchestrationV2PrivateEvent.Type; export const OrchestrationV2ThreadProjection = Schema.Struct({ thread: OrchestrationV2AppThread, runs: Schema.Array(OrchestrationV2Run), @@ -1712,6 +1780,7 @@ export type OrchestrationV2LatestVisibleMessageSummary = typeof OrchestrationV2LatestVisibleMessageSummary.Type; export const OrchestrationV2ThreadShell = Schema.Struct({ + runtimeIdentity: Schema.optional(RuntimeIdentityAttestation), ...OrchestrationV2CreationFields, id: ThreadId, projectId: ProjectId, @@ -1720,6 +1789,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.optional(Schema.Boolean), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), /** Pull request the user linked to this thread (#8160). */ @@ -1863,7 +1933,6 @@ export const OrchestrationV2StoredEvent = Schema.Struct({ event: OrchestrationV2DomainEvent, }); export type OrchestrationV2StoredEvent = typeof OrchestrationV2StoredEvent.Type; - export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields((fields) => ({ ...fields, createdAt: Schema.DateTimeUtcFromString, @@ -2466,7 +2535,6 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ }), ]); export type OrchestrationV2DomainEventJson = typeof OrchestrationV2DomainEventJson.Type; - export const OrchestrationV2StoredEventJson = Schema.Struct({ sequence: NonNegativeInt, commandId: Schema.NullOr(CommandId), @@ -2606,6 +2674,7 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("thread.metadata.update"), commandId: CommandId, threadId: ThreadId, + threadMessagesBlocked: Schema.optional(Schema.Boolean), title: Schema.optional(TrimmedNonEmptyString), /** Kick off (true) or abandon (false) an async title regeneration. */ regenerateTitle: Schema.optional(Schema.Boolean), @@ -2702,6 +2771,9 @@ export const OrchestrationV2Command = Schema.Union([ }), Schema.Struct({ type: Schema.Literal("message.dispatch"), + dispatchGuard: Schema.optional(ThreadTurnDispatchGuard), + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), notification: Schema.optional(OrchestrationV2Notification), ...OrchestrationV2CreationFields, scheduledTaskId: Schema.optional(ScheduledTaskId), @@ -2722,6 +2794,8 @@ export const OrchestrationV2Command = Schema.Union([ usageLimitRecoveryRequestId: Schema.optional(CommandId), /** Resolve untargeted delivery against the server's serialized thread state. */ deliveryIntent: Schema.optional(Schema.Literals(["auto", "steer", "restart"])), + /** Explicitly opt an owner follow-up into automatic tool-boundary delivery if queued. */ + queuedToolBoundaryEligible: Schema.optional(Schema.Boolean), delegatedCompletion: Schema.optional( Schema.Struct({ parentRunId: RunId, @@ -2733,6 +2807,7 @@ export const OrchestrationV2Command = Schema.Union([ Schema.Struct({ type: Schema.Literal("defer_start"), workspaceStrategy: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), + runSetupScript: Schema.optional(Schema.Boolean), }), Schema.Struct({ type: Schema.Literal("steer_active"), targetRunId: RunId }), Schema.Struct({ type: Schema.Literal("restart_active"), targetRunId: RunId }), @@ -2812,6 +2887,7 @@ export const OrchestrationV2Command = Schema.Union([ context: Schema.optional(OrchestrationMessageContext), commandId: CommandId, threadId: ThreadId, + senderThreadId: Schema.optional(ThreadId), runId: RunId, text: Schema.String, // Full replacement list. Absent = leave the message's attachments as-is, @@ -2822,6 +2898,7 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("runtime-request.respond"), commandId: CommandId, threadId: ThreadId, + senderThreadId: Schema.optional(ThreadId), requestId: RuntimeRequestId, decision: Schema.optional(ProviderApprovalDecision), answers: Schema.optional(ProviderUserInputAnswers), @@ -2921,6 +2998,13 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; * send them. */ const OrchestrationV2InternalCommand = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("legacy-bootstrap.failure-delete"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + legacyBootstrap: OrchestrationV2LegacyBootstrapPolicy, + }), /** * Records what a pull request watch saw, and wakes the agent in the same transaction when * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is @@ -2967,7 +3051,13 @@ const OrchestrationV2InternalCommand = Schema.Union([ export type OrchestrationV2InternalCommand = typeof OrchestrationV2InternalCommand.Type; /** Everything the server's orchestrator accepts: client commands plus internal ones. */ -export type OrchestrationV2ServerCommand = OrchestrationV2Command | OrchestrationV2InternalCommand; +export type OrchestrationV2ServerCommand = + | (OrchestrationV2Command & { + /** Private receiving input; absent from both public dispatch and launch schemas. */ + readonly legacyBootstrap?: OrchestrationV2LegacyBootstrapPolicy; + readonly legacyPreparationFailureKnown?: boolean; + }) + | OrchestrationV2InternalCommand; export const ORCHESTRATION_V2_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -3035,6 +3125,7 @@ export const OrchestrationV2ThreadLaunchInput = Schema.Struct({ export type OrchestrationV2ThreadLaunchInput = typeof OrchestrationV2ThreadLaunchInput.Type; export const OrchestrationV2ThreadLaunchResult = Schema.Struct({ + creation: Schema.optionalKey(NativeCreationObservation), threadId: ThreadId, projection: OrchestrationV2ThreadProjection, resumed: Schema.Boolean, @@ -3204,6 +3295,7 @@ export type OrchestrationV2ThreadStreamItem = typeof OrchestrationV2ThreadStream export class OrchestrationV2DispatchCommandError extends Schema.TaggedError()( "OrchestrationV2DispatchCommandError", { + creationRejectionCode: Schema.optionalKey(NativeCreationRejectionCode), commandId: CommandId, commandType: Schema.String, message: Schema.String, diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 617442ece..f1f37b223 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { + OrchestratorMcpThreadSettleInput, + OrchestratorMcpThreadSettleResult, OrchestratorMcpCreateThreadsInput, OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, @@ -164,3 +166,30 @@ describe("orchestrator MCP contracts", () => { ).toBe("Loop converged."); }); }); + +describe("self settlement contracts", () => { + it("requires a stable request key and has no other-thread selector", () => { + const decode = Schema.decodeUnknownSync(OrchestratorMcpThreadSettleInput, { + onExcessProperty: "error", + }); + expect(decode({ clientRequestId: "finish-1" })).toEqual({ clientRequestId: "finish-1" }); + for (const input of [ + {}, + { clientRequestId: "" }, + { clientRequestId: "finish-1", threadId: "other" }, + ]) { + expect(() => decode(input)).toThrow(); + } + }); + it("reports accepted intent bound to the original run rather than completed settlement", () => { + const decode = Schema.decodeUnknownSync(OrchestratorMcpThreadSettleResult); + const receipt = { + status: "accepted", + threadId: "thread-1", + runId: "run-1", + clientRequestId: "finish-1", + }; + expect(decode(receipt)).toEqual(receipt); + expect(() => decode({ ...receipt, status: "settled" })).toThrow(); + }); +}); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 565f42866..f698a6ce4 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -303,6 +303,7 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ model: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.optional(Schema.Boolean), linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), settled: Schema.Boolean, settledAt: Schema.NullOr(IsoDateTime), @@ -348,6 +349,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ model: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + threadMessagesBlocked: Schema.optional(Schema.Boolean), linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), branch: Schema.NullOr(Schema.String), @@ -587,3 +589,16 @@ export class OrchestratorMcpFailure extends Schema.TaggedError { it("accepts the inline preview visibility flag", () => { @@ -172,6 +174,26 @@ describe("PreviewAutomationHost", () => { }).supportedOperations, ).toEqual(["status", "resize"]); }); + it("accepts a versioned runtime descriptor and rejects incompatible versions", () => { + const runtimeIdentity = { + schemaVersion: 1, + runtimeKind: "electron", + runtimeInstanceId: "runtime-1", + appVersion: "0.1.0", + buildCommit: "a".repeat(40), + }; + expect(decodeRuntimeIdentity(runtimeIdentity)).toEqual(runtimeIdentity); + expect( + decodeAutomationHost({ clientId: "current", environmentId: "environment-1", runtimeIdentity }) + .runtimeIdentity, + ).toEqual(runtimeIdentity); + expect(() => decodeRuntimeIdentity({ ...runtimeIdentity, schemaVersion: 2 })).toThrow(); + expect(() => decodeRuntimeIdentity({ ...runtimeIdentity, runtimeKind: "browser" })).toThrow(); + expect(() => + decodeRuntimeIdentity({ ...runtimeIdentity, buildCommit: "a".repeat(39) }), + ).toThrow(); + expect(decodeRuntimeIdentity({ ...runtimeIdentity, buildCommit: null }).buildCommit).toBeNull(); + }); }); describe("PreviewAutomationError", () => { @@ -203,6 +225,24 @@ describe("PreviewAutomationError", () => { }); describe("PreviewAutomationStatus", () => { + it("accepts an optional broker-selected client receipt with a legacy null descriptor", () => { + const status = { + available: true, + visible: false, + tabId: null, + url: null, + title: null, + loading: false, + selectedClient: { + clientId: "legacy", + connectionId: "connection-1", + requestId: "preview-0", + completedAt: "2026-09-27T00:00:00.000Z", + runtimeIdentity: null, + }, + }; + expect(decodeAutomationStatus(status)).toEqual(status); + }); it("accepts old hosts without viewport data and exposes it from current hosts", () => { const base = { available: true, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index f87739670..3bafc37f9 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -63,6 +63,20 @@ const PreviewAutomationTabTargetFields = { export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; +export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type; +export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); +export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type; + +export const PreviewAutomationRuntimeIdentity = Schema.Struct({ + schemaVersion: Schema.Literal(1), + runtimeKind: Schema.Literal("electron"), + runtimeInstanceId: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + appVersion: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + buildCommit: Schema.NullOr(Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/))), +}); +export type PreviewAutomationRuntimeIdentity = typeof PreviewAutomationRuntimeIdentity.Type; + export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, visible: Schema.Boolean, @@ -74,6 +88,15 @@ export const PreviewAutomationStatus = Schema.Struct({ viewportSetting: Schema.optional(PreviewViewportSetting), /** Measured guest-page viewport in CSS pixels when a webview is ready. */ viewport: Schema.optional(PreviewRenderedViewportSize), + selectedClient: Schema.optional( + Schema.Struct({ + clientId: PreviewAutomationClientId, + connectionId: PreviewAutomationConnectionId, + requestId: TrimmedNonEmptyString, + completedAt: Schema.String, + runtimeIdentity: Schema.NullOr(PreviewAutomationRuntimeIdentity), + }), + ), }); export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; @@ -565,11 +588,6 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({ }); export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; -export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); -export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type; -export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); -export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type; - export const PreviewAutomationHostIdentity = Schema.Struct({ clientId: PreviewAutomationClientId, environmentId: EnvironmentId, @@ -583,6 +601,7 @@ export const PreviewAutomationHost = Schema.Struct({ * a newer server safely coexist with an older desktop during rollout. */ supportedOperations: Schema.optional(Schema.Array(PreviewAutomationOperation)), + runtimeIdentity: Schema.optional(PreviewAutomationRuntimeIdentity), }); export type PreviewAutomationHost = typeof PreviewAutomationHost.Type; diff --git a/packages/contracts/src/providerQueue.ts b/packages/contracts/src/providerQueue.ts new file mode 100644 index 000000000..aa0728965 --- /dev/null +++ b/packages/contracts/src/providerQueue.ts @@ -0,0 +1,190 @@ +import * as Schema from "effect/Schema"; +import { + CommandId, + MessageId, + ThreadId, + TurnId, + NonNegativeInt, + IsoDateTime, +} from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +export const QualifiedQuotaWindow = Schema.Struct({ + usedPercent: Schema.Finite, + resetsAt: Schema.optionalKey(Schema.NullOr(Schema.Finite)), + windowDurationMins: Schema.optionalKey(Schema.NullOr(Schema.Finite)), +}); +export const QualifiedQuotaBucket = Schema.Struct({ + credits: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optionalKey(Schema.NullOr(Schema.String)), + hasCredits: Schema.Boolean, + unlimited: Schema.Boolean, + }), + ), + ), + individualLimit: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + limit: Schema.String, + remainingPercent: Schema.Finite, + resetsAt: Schema.Finite, + used: Schema.String, + }), + ), + ), + limitId: Schema.optionalKey(Schema.NullOr(Schema.String)), + limitName: Schema.optionalKey(Schema.NullOr(Schema.String)), + normalModelSlug: Schema.optionalKey(Schema.NullOr(Schema.String)), + planType: Schema.optionalKey(Schema.NullOr(Schema.String)), + primary: Schema.optionalKey(Schema.NullOr(QualifiedQuotaWindow)), + secondary: Schema.optionalKey(Schema.NullOr(QualifiedQuotaWindow)), + rateLimitReachedType: Schema.optionalKey(Schema.NullOr(Schema.String)), + spendControlReached: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), +}); +export const QualifiedQuotaMap = Schema.Record(Schema.String, QualifiedQuotaBucket); +export type QualifiedQuotaMap = typeof QualifiedQuotaMap.Type; +export const ProviderQueueCapabilityRef = Schema.Struct({ + modelId: Schema.String, + reasoningEfforts: Schema.Array(Schema.String), + source: Schema.Literal("model/list"), + probeId: Schema.String.check(Schema.isUUID()), + observedAt: IsoDateTime, +}); +export type ProviderQueueCapabilityRef = typeof ProviderQueueCapabilityRef.Type; +export const QualifiedQuotaFailureCode = Schema.Literals([ + "probe_failed", + "probe_timeout", + "quota_unavailable", + "missing_map", + "incomplete_windows", + "unsupported_account", + "disabled", + "refresh_not_observed", + "interrupted_attempt", + "reset_crossed", +]); +export const QualifiedQuota = Schema.Struct({ + schemaVersion: Schema.Literal("codex.t3-qualified-quota/v1"), + instanceId: ProviderInstanceId, + probeId: Schema.String.check(Schema.isUUID()), + status: Schema.Literals(["qualified", "unknown", "failed", "unsupported"]), + attemptedAt: IsoDateTime, + quotaReceivedAt: Schema.NullOr(IsoDateTime), + probeCompletedAt: IsoDateTime, + complete: Schema.Boolean, + rateLimitsByLimitId: Schema.NullOr(QualifiedQuotaMap), + windowProvenance: Schema.Array( + Schema.Struct({ + limitId: Schema.String, + windowId: Schema.Literals(["primary", "secondary"]), + probeId: Schema.String.check(Schema.isUUID()), + quotaReceivedAt: IsoDateTime, + }), + ), + failureCode: Schema.NullOr(QualifiedQuotaFailureCode), + capabilityRefs: Schema.Array(ProviderQueueCapabilityRef), +}); +export type QualifiedQuota = typeof QualifiedQuota.Type; +export const ProviderQueueInventory = Schema.Struct({ + schemaVersion: Schema.Literal("t3.provider-queue-inventory/v1"), + inventoryRevision: Schema.String, + observedAt: IsoDateTime, + evidence: Schema.Literal("configured-provider-registry"), + instances: Schema.Array( + Schema.Struct({ + instanceId: ProviderInstanceId, + displayName: Schema.String, + driver: Schema.Literal("codex"), + enabled: Schema.Boolean, + capabilityRefs: Schema.Array(ProviderQueueCapabilityRef), + }), + ), +}); +export type ProviderQueueInventory = typeof ProviderQueueInventory.Type; +export const ProviderQueueRefreshResult = Schema.Struct({ + instanceId: ProviderInstanceId, + status: Schema.Literals([ + "refreshed", + "cached", + "unknown_instance", + "unsupported", + "unavailable", + ]), + nextRefreshAt: Schema.NullOr(IsoDateTime), + quota: Schema.NullOr(QualifiedQuota), +}); +export type ProviderQueueRefreshResult = typeof ProviderQueueRefreshResult.Type; + +import { ModelSelection } from "./modelSelection.ts"; + +export const OrchestrationSessionStatus = Schema.Literals([ + "idle", + "starting", + "running", + "ready", + "interrupted", + "stopped", + "error", +]); +export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type; + +export const ThreadTurnDispatchGuard = Schema.Struct({ + observedSnapshotSequence: NonNegativeInt, + expectedModelSelection: ModelSelection, + expectedSessionStatus: Schema.NullOr(OrchestrationSessionStatus), + expectedActiveTurnId: Schema.NullOr(TurnId), + expectedLatestTurnId: Schema.NullOr(TurnId), + requireIdle: Schema.Literal(true), +}); +export type ThreadTurnDispatchGuard = typeof ThreadTurnDispatchGuard.Type; + +export const OrchestrationDispatchTarget = Schema.Struct({ + modelSelection: ModelSelection, + sessionStatus: Schema.NullOr(OrchestrationSessionStatus), + activeTurnId: Schema.NullOr(TurnId), + latestTurnId: Schema.NullOr(TurnId), + requireIdle: Schema.Literal(true), + idle: Schema.Boolean, + blockers: Schema.Array( + Schema.Literals([ + "archived", + "settled", + "pending_turn", + "running_turn", + "session_starting", + "session_running", + "active_turn", + "pending_approval", + "pending_user_input", + "actionable_plan", + "background_work", + ]), + ), +}); +export type OrchestrationDispatchTarget = typeof OrchestrationDispatchTarget.Type; + +export const OrchestrationObservedTurn = Schema.Struct({ + turnId: Schema.NullOr(TurnId), + state: Schema.Literals(["pending", "running", "interrupted", "completed", "error"]), + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + assistantMessageId: Schema.NullOr(MessageId), +}); +export type OrchestrationObservedTurn = typeof OrchestrationObservedTurn.Type; + +// Correlation is historical projection evidence, not proof of task delivery. +export const OrchestrationCommandObservation = Schema.Struct({ + threadId: ThreadId, + commandId: CommandId, + messageId: MessageId, + snapshotSequence: NonNegativeInt, + commandStatus: Schema.Literals(["accepted", "rejected", "not_found"]), + acceptedSequence: Schema.NullOr(NonNegativeInt), + correlation: Schema.Literals(["exact", "pending", "missing", "ambiguous", "mismatched"]), + turn: Schema.NullOr(OrchestrationObservedTurn), + target: Schema.NullOr(OrchestrationDispatchTarget), +}); +export type OrchestrationCommandObservation = typeof OrchestrationCommandObservation.Type; diff --git a/packages/contracts/src/providerRuntimeIdentity.test.ts b/packages/contracts/src/providerRuntimeIdentity.test.ts new file mode 100644 index 000000000..8b1e0d4ad --- /dev/null +++ b/packages/contracts/src/providerRuntimeIdentity.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { + RuntimeIdentityAttestation, + RuntimeIdentityObservation, + ProviderRuntimeBinding, +} from "./providerRuntimeIdentity.ts"; + +const decodeIdentity = Schema.decodeUnknownSync(RuntimeIdentityAttestation); +const decodeObservation = Schema.decodeUnknownSync(RuntimeIdentityObservation); +const decodeBinding = Schema.decodeUnknownSync(ProviderRuntimeBinding); + +describe("provider runtime identity", () => { + it("keeps requested routing separate from observed runtime identity", () => { + const identity = decodeIdentity({ + runtimeGeneration: "actual-process-1", + evidenceRevision: 4, + requested: { + providerInstanceId: "codex", + providerDriver: "codex", + model: "requested", + serviceTier: "priority", + }, + observed: { + backend: { status: "observed", value: "native-backend", sourceEvent: "codex.thread/open" }, + model: { status: "unknown" }, + account: { status: "unavailable", reason: "The native protocol does not bind an account." }, + serviceTier: { status: "unavailable", reason: "No native tier was reported." }, + }, + }); + expect(identity.requested.model).toBe("requested"); + expect(identity.observed.model).toEqual({ status: "unknown" }); + expect(identity.observed.account.status).toBe("unavailable"); + expect(identity.observed.serviceTier.status).toBe("unavailable"); + }); + + it.each([ + { status: "observed", value: "", sourceEvent: "native" }, + { status: "observed", value: "native", sourceEvent: " " }, + { status: "unavailable", reason: " " }, + ])("rejects unusable native evidence: %j", (observation) => { + expect(() => decodeObservation(observation)).toThrow(); + }); + + it("requires a producer generation for an identity-bearing native binding", () => { + const binding = { + threadId: "app", + providerThreadId: "provider-thread", + providerSessionId: "logical-session", + providerInstanceId: "codex", + driver: "codex", + nativeThreadId: "native-thread", + }; + expect(() => decodeBinding(binding)).toThrow(); + expect( + decodeBinding({ ...binding, runtimeGeneration: "actual-process" }).runtimeGeneration, + ).toBe("actual-process"); + }); +}); diff --git a/packages/contracts/src/providerRuntimeIdentity.ts b/packages/contracts/src/providerRuntimeIdentity.ts new file mode 100644 index 000000000..2f34a19cd --- /dev/null +++ b/packages/contracts/src/providerRuntimeIdentity.ts @@ -0,0 +1,64 @@ +import * as Schema from "effect/Schema"; +import { + NonNegativeInt, + ProviderSessionId, + ProviderThreadId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +/** Missing native evidence must never be filled from routing or authentication metadata. */ +export const RuntimeIdentityObservation = Schema.Union([ + Schema.Struct({ status: Schema.Literal("unknown") }), + Schema.Struct({ status: Schema.Literal("unavailable"), reason: TrimmedNonEmptyString }), + Schema.Struct({ + status: Schema.Literal("observed"), + value: TrimmedNonEmptyString, + sourceEvent: TrimmedNonEmptyString, + }), +]); +export type RuntimeIdentityObservation = typeof RuntimeIdentityObservation.Type; + +export const ObservedRuntimeIdentity = Schema.Struct({ + backend: RuntimeIdentityObservation, + model: RuntimeIdentityObservation, + account: RuntimeIdentityObservation, + serviceTier: RuntimeIdentityObservation, +}); +export type ObservedRuntimeIdentity = typeof ObservedRuntimeIdentity.Type; + +export const RequestedRuntimeIdentity = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + providerDriver: ProviderDriverKind, + model: TrimmedNonEmptyString, + serviceTier: Schema.NullOr(TrimmedNonEmptyString), +}); +export type RequestedRuntimeIdentity = typeof RequestedRuntimeIdentity.Type; + +export const RuntimeIdentityAttestation = Schema.Struct({ + runtimeGeneration: Schema.optional(TrimmedNonEmptyString), + evidenceRevision: Schema.optional(NonNegativeInt), + requested: RequestedRuntimeIdentity, + observed: ObservedRuntimeIdentity, +}); +export type RuntimeIdentityAttestation = typeof RuntimeIdentityAttestation.Type; + +/** A process incarnation and the native conversation it actually owns are independent IDs. */ +export const ProviderRuntimeBinding = Schema.Struct({ + threadId: ThreadId, + providerThreadId: ProviderThreadId, + providerSessionId: ProviderSessionId, + providerInstanceId: ProviderInstanceId, + driver: ProviderDriverKind, + nativeThreadId: TrimmedNonEmptyString, + runtimeGeneration: TrimmedNonEmptyString, +}); +export type ProviderRuntimeBinding = typeof ProviderRuntimeBinding.Type; + +export const ProviderRuntimeEvidenceCapture = Schema.Struct({ + ...ProviderRuntimeBinding.fields, + runtimeGeneration: Schema.optional(TrimmedNonEmptyString), + evidenceRevision: Schema.optional(NonNegativeInt), +}); +export type ProviderRuntimeEvidenceCapture = typeof ProviderRuntimeEvidenceCapture.Type; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index e6f9f6177..e8fa23af2 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1422,3 +1422,55 @@ export class PullRequestOperationError extends Schema.TaggedError(item: A) => + Schema.Struct({ + state: PullRequestCiAvailability, + reasons: Schema.Array(Schema.String), + items: Schema.Array(item), + }); +export const PullRequestCiJob = Schema.Struct({ + id: PositiveInt, + runId: PositiveInt, + repository: Schema.String, + name: Schema.String, + status: Schema.Literals(["queued", "in_progress"]), + url: Schema.NullOr(Schema.String), + runnerId: Schema.NullOr(PositiveInt), + runnerName: Schema.NullOr(Schema.String), + startedAt: Schema.NullOr(IsoDateTime), +}); +export const PullRequestCiWorkflow = Schema.Struct({ + id: PositiveInt, + repository: Schema.String, + name: Schema.String, + status: Schema.String, + url: Schema.NullOr(Schema.String), +}); +export const PullRequestCiRunner = Schema.Struct({ + id: PositiveInt, + name: Schema.String, + status: Schema.Literals(["online", "offline", "unknown"]), + busy: Schema.Boolean, + labels: Schema.Array(Schema.String), +}); +export const PullRequestCiStatusResult = Schema.Struct({ + host: Schema.String, + organization: Schema.String, + accountId: Schema.NullOr(Schema.String), + observedAt: IsoDateTime, + repositories: Schema.Array(Schema.String), + scopeTruncated: Schema.Boolean, + jobs: ciSection(PullRequestCiJob), + workflows: ciSection(PullRequestCiWorkflow), + runners: ciSection(PullRequestCiRunner), +}); +export type PullRequestCiStatusResult = typeof PullRequestCiStatusResult.Type; diff --git a/packages/contracts/src/queueDispatch.ts b/packages/contracts/src/queueDispatch.ts new file mode 100644 index 000000000..1b7fb4c25 --- /dev/null +++ b/packages/contracts/src/queueDispatch.ts @@ -0,0 +1,98 @@ +import * as Schema from "effect/Schema"; +import * as SchemaParser from "effect/SchemaParser"; +import { + CommandId, + MessageId, + ThreadId, + TurnId, + ProjectId, + PlanId, + IsoDateTime, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { ChatAttachment, UploadChatAttachment } from "./chatAttachment.ts"; +import { OrchestrationMessageContext } from "./composerContext.ts"; +import { ModelSelection } from "./modelSelection.ts"; +import { RuntimeMode, ProviderInteractionMode } from "./providerPolicy.ts"; +import { ThreadTurnDispatchGuard } from "./providerQueue.ts"; +import { ProjectMutation } from "./project.ts"; + +const QueueBootstrapThread = Schema.Struct({ + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + createdAt: IsoDateTime, +}); +const QueueBootstrap = Schema.Struct({ + createThread: Schema.optional(QueueBootstrapThread), + prepareWorktree: Schema.optional( + Schema.Struct({ + projectCwd: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + branch: Schema.optional(TrimmedNonEmptyString), + startFromOrigin: Schema.optional(Schema.Boolean), + requireWorktree: Schema.optional(Schema.Boolean), + }), + ), + runSetupScript: Schema.optional(Schema.Boolean), +}); + +// The queue bridge names supported legacy commands explicitly; unsupported V1 fields fail closed. +const QueueDispatchCommandShape = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("thread.turn.start"), + commandId: CommandId, + threadId: ThreadId, + message: Schema.Struct({ + messageId: MessageId, + role: Schema.Literal("user"), + text: Schema.String, + attachments: Schema.Array(Schema.Union([ChatAttachment, UploadChatAttachment])), + context: Schema.optional(OrchestrationMessageContext), + }), + modelSelection: Schema.optional(ModelSelection), + titleSeed: Schema.optional(TrimmedNonEmptyString), + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + bootstrap: Schema.optional(QueueBootstrap), + dispatchGuard: Schema.optional(ThreadTurnDispatchGuard), + sourceProposedPlan: Schema.optional(Schema.Struct({ threadId: ThreadId, planId: PlanId })), + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("thread.turn.interrupt"), + commandId: CommandId, + threadId: ThreadId, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("thread.create"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + createdAt: IsoDateTime, + }), + ProjectMutation, +]); + +export const QueueDispatchCommand = Schema.declareConstructor< + typeof QueueDispatchCommandShape.Type, + typeof QueueDispatchCommandShape.Encoded +>()( + [QueueDispatchCommandShape], + ([codec]) => + (input, _ast, options) => + SchemaParser.decodeUnknownEffect(codec)(input, { ...options, onExcessProperty: "error" }), +); +export type QueueDispatchCommand = typeof QueueDispatchCommand.Type; diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts index 7b2832e89..32d21aefa 100644 --- a/packages/contracts/src/rpc.test.ts +++ b/packages/contracts/src/rpc.test.ts @@ -68,3 +68,60 @@ describe("WebSocket RPC contracts", () => { ).toBe(true); }); }); + +describe("legacy queue compatibility on the existing dispatch RPC", () => { + const rpc = WsRpcGroup.requests.get(ORCHESTRATION_V2_WS_METHODS.dispatchCommand)!; + const payload = { + type: "thread.turn.start", + commandId: "legacy:C", + threadId: "legacy:T", + createdAt: "2026-10-05T00:00:00.000Z", + message: { messageId: "legacy:M", role: "user", text: "Prompt", attachments: [] }, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { runSetupScript: false }, + dispatchGuard: { + observedSnapshotSequence: 3, + expectedModelSelection: { instanceId: "codex", model: "gpt-6" }, + expectedSessionStatus: null, + expectedActiveTurnId: null, + expectedLatestTurnId: null, + requireIdle: true, + }, + }; + it("preserves the complete closed legacy command and final guard without changing method identity", () => { + expect(Schema.decodeUnknownSync(rpc.payloadSchema)(payload)).toEqual(payload); + }); + it("rejects undeclared bootstrap fields while preserving native V2 excess-field behavior", () => { + expect( + Exit.isFailure( + Schema.decodeUnknownExit(rpc.payloadSchema)({ + ...payload, + bootstrap: { ...payload.bootstrap, transport: "legacy_websocket" }, + }), + ), + ).toBe(true); + expect( + Exit.isSuccess( + Schema.decodeUnknownExit(rpc.payloadSchema)({ + type: "checkpoint.rollback", + commandId: "native:rollback", + threadId: "native:T", + scopeId: "scope", + checkpointId: "checkpoint", + futureNativeField: true, + }), + ), + ).toBe(true); + }); + it.each(["transport", "unsupported", "legacyBootstrap"])( + "rejects a caller-controlled %s field instead of discarding it", + (key) => { + expect( + Exit.isFailure( + Schema.decodeUnknownExit(rpc.payloadSchema)({ ...payload, [key]: "forged" }), + ), + ).toBe(true); + }, + ); +}); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9fb0f1867..b5b6408cc 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,4 @@ +import { QueueDispatchCommand } from "./queueDispatch.ts"; import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts"; import { ChatGptReconnectProfileInput, @@ -144,6 +145,8 @@ import { } from "./provider.ts"; import { ProviderInstanceId, ProviderInstanceMutation } from "./providerInstance.ts"; import { + PullRequestCiStatusInput, + PullRequestCiStatusResult, PullRequestActionInput, PullRequestActivity, PullRequestCommentInput, @@ -302,6 +305,7 @@ import { ProviderConsumeResetCreditResult, } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; +import { TokenAccountingReadInput, TokenAccountingReadResult } from "./tokenAccounting.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { ScheduledTaskDeleteInput, @@ -465,6 +469,7 @@ export const WS_METHODS = { serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + serverReadTokenAccounting: "server.readTokenAccounting", serverRefreshUsageRates: "server.refreshUsageRates", // Scheduled tasks @@ -480,6 +485,7 @@ export const WS_METHODS = { cloudInstallRelayClient: "cloud.installRelayClient", // Pull request methods + pullRequestsCiStatus: "pullRequests.ciStatus", pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", pullRequestsSummary: "pullRequests.summary", @@ -836,6 +842,12 @@ const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), }); +const WsServerReadTokenAccountingRpc = Rpc.make(WS_METHODS.serverReadTokenAccounting, { + payload: TokenAccountingReadInput, + success: TokenAccountingReadResult, + error: EnvironmentAuthorizationError, +}); + /** * Refetches the model rate table ahead of its daily TTL, so a model released * since the last fetch gets priced. The next usage summary uses the new table. @@ -887,6 +899,12 @@ const PullRequestRpcError = Schema.Union([ EnvironmentAuthorizationError, ]); +const WsPullRequestsCiStatusRpc = Rpc.make(WS_METHODS.pullRequestsCiStatus, { + payload: PullRequestCiStatusInput, + success: PullRequestCiStatusResult, + error: PullRequestRpcError, +}); + const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { payload: PullRequestListInput, success: PullRequestListResult, @@ -1496,9 +1514,13 @@ const WsSubscribeDeviceStateRpc = Rpc.make(WS_METHODS.subscribeDeviceState, { }); const WsOrchestrationV2DispatchCommandRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, { - payload: OrchestrationV2RpcSchemas.dispatchCommand.input, + payload: Schema.Union([OrchestrationV2RpcSchemas.dispatchCommand.input, QueueDispatchCommand]), success: OrchestrationV2RpcSchemas.dispatchCommand.output, - error: Schema.Union([OrchestrationV2DispatchCommandError, EnvironmentAuthorizationError]), + error: Schema.Union([ + OrchestrationV2DispatchCommandError, + OrchestrationDispatchCommandError, + EnvironmentAuthorizationError, + ]), }); const WsOrchestrationV2GetTurnDiffRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnDiff, { @@ -1745,6 +1767,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, + WsServerReadTokenAccountingRpc, WsServerRefreshUsageRatesRpc, WsServerSignalProcessRpc, WsScheduledTasksListRpc, @@ -1758,6 +1781,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetBackgroundPolicyRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, + WsPullRequestsCiStatusRpc, WsPullRequestsListRpc, WsPullRequestsListStatsRpc, WsPullRequestsSummaryRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e152a3dbf..a79aa0f2c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,3 +1,4 @@ +import { QualifiedQuota } from "./providerQueue.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { AcpRegistryUrlAuthAction } from "./acpRegistry.ts"; @@ -276,6 +277,7 @@ export const ServerProvider = Schema.Struct({ workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), // Absent when the driver has no notion of subscription usage. usageLimits: Schema.optional(ServerProviderUsageLimits), + qualifiedQuota: Schema.optionalKey(QualifiedQuota), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), diff --git a/packages/contracts/src/threadMetadataMcp.test.ts b/packages/contracts/src/threadMetadataMcp.test.ts index e07477cf9..7fc6cb5ea 100644 --- a/packages/contracts/src/threadMetadataMcp.test.ts +++ b/packages/contracts/src/threadMetadataMcp.test.ts @@ -32,12 +32,18 @@ describe("ThreadMetadataMcpUpdateInput", () => { }, }, ); + for (const action of ["block_thread_messages", "allow_thread_messages"] as const) { + assert.deepEqual(decodeUpdate({ action }), { action }); + } assert.deepEqual(decodeUpdate({ action: "unlink_pull_request" }), { action: "unlink_pull_request", }); }); it("rejects missing action data and fields from another action", () => { + for (const action of ["block_thread_messages", "allow_thread_messages"] as const) { + assert.throws(() => decodeUpdate({ action, title: "Not allowed" })); + } assert.throws(() => decodeUpdate({ action: "rename" })); assert.throws(() => decodeUpdate({ action: "regenerate_title", title: "Not allowed" })); assert.throws(() => decodeUpdate({ action: "link_pull_request" })); diff --git a/packages/contracts/src/threadMetadataMcp.ts b/packages/contracts/src/threadMetadataMcp.ts index ca15a524d..75d351ee8 100644 --- a/packages/contracts/src/threadMetadataMcp.ts +++ b/packages/contracts/src/threadMetadataMcp.ts @@ -63,9 +63,11 @@ export const ThreadMetadataMcpAction = Schema.Literals([ "regenerate_title", "link_pull_request", "unlink_pull_request", + "block_thread_messages", + "allow_thread_messages", ]).annotate({ description: - "Metadata mutation: rename, regenerate_title, link_pull_request, or unlink_pull_request.", + "Metadata mutation: rename, regenerate_title, link_pull_request, unlink_pull_request, block_thread_messages, or allow_thread_messages. Only the calling thread can allow its messages again.", }); export type ThreadMetadataMcpAction = typeof ThreadMetadataMcpAction.Type; @@ -101,6 +103,8 @@ export const ThreadMetadataMcpUpdateInput = Schema.Struct({ : "link_pull_request requires pullRequest and does not accept title."; case "regenerate_title": case "unlink_pull_request": + case "block_thread_messages": + case "allow_thread_messages": return input.title === undefined && input.pullRequest === undefined ? true : `${input.action} does not accept title or pullRequest.`; @@ -114,6 +118,7 @@ export const ThreadMetadataMcpUpdateResult = Schema.Struct({ action: ThreadMetadataMcpAction, commandId: CommandId, sequence: NonNegativeInt, + threadMessagesBlocked: Schema.optional(Schema.Boolean), title: Schema.String, titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), diff --git a/packages/contracts/src/tokenAccounting.test.ts b/packages/contracts/src/tokenAccounting.test.ts new file mode 100644 index 000000000..f5ba659b1 --- /dev/null +++ b/packages/contracts/src/tokenAccounting.test.ts @@ -0,0 +1,219 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + TokenAccountingMetric, + TokenAccountingReadInput, + TokenAccountingReadResult, + TokenAccountingReport, +} from "./tokenAccounting.ts"; + +describe("saved token accounting projection", () => { + it("preserves a known sum independently from an unknown complete total", () => { + const metric = { known_sum: 42, total: null, known_requests: 1, missing_requests: 1 }; + expect(Schema.decodeUnknownSync(TokenAccountingMetric)(metric)).toEqual(metric); + }); + + it("preserves entirely unknown metrics instead of normalizing them to zero", () => { + const metric = { known_sum: null, total: null, known_requests: 0, missing_requests: 2 }; + expect(Schema.decodeUnknownSync(TokenAccountingMetric)(metric)).toEqual(metric); + }); + + it("rejects unsafe counts and undeclared metric fields", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingMetric); + const metric = { known_sum: null, total: null, known_requests: 0, missing_requests: 2 }; + expect(() => decode({ ...metric, known_sum: Number.MAX_SAFE_INTEGER + 1 })).toThrow(); + expect(() => decode({ ...metric, complete: true })).toThrow(); + }); + + it("rejects an original report presented as the consumer projection", () => { + expect(() => + Schema.decodeUnknownSync(TokenAccountingReport)({ + schema: "programmatic-token-info.accounting-report/v1", + report_id: "a".repeat(64), + }), + ).toThrow(); + }); + + it("rejects caller paths, report selectors and execution flags", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReadInput); + expect(decode({})).toEqual({}); + for (const input of [{ path: "saved.json" }, { reportId: "a".repeat(64) }, { args: [] }]) { + expect(() => decode(input)).toThrow(); + } + }); + + it("keeps status and reason paired and rejects diagnostic fields", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReadResult); + const result = { + state: "unavailable", + status: "missing", + reason: "configured_report_missing", + configuredReportId: "a".repeat(64), + readAt: "2026-10-02T12:00:00Z", + }; + expect(decode(result)).toEqual(result); + expect(() => decode({ ...result, status: "unsupported" })).toThrow(); + expect(() => decode({ ...result, stderr: "synthetic diagnostic" })).toThrow(); + expect(() => decode({ ...result, configuredReportId: "A".repeat(64) })).toThrow(); + }); + + it("accepts native allocation pairs and rejects impossible pairs in either direction", () => { + const decodeInput = Schema.decodeUnknownSync(TokenAccountingReport.fields.input.fields.groups); + const decodeOutput = Schema.decodeUnknownSync( + TokenAccountingReport.fields.output.fields.groups, + ); + const group = (name: string, subtype: string) => ({ + group: name, + subtype, + basis: "measured_component", + central: 0, + low: 0, + high: 0, + }); + const input = [ + group("guidance", "system"), + group("tool_result", "file_read_code"), + group("assistant_history", "tool_call_arguments"), + group("tool_schema_config", "unknown"), + ]; + const output = [ + group("assistant_text", "final"), + group("tool_call_arguments", "agent_launch"), + group("other_generated", "unknown"), + ]; + expect(decodeInput(input)).toEqual(input); + expect(decodeOutput(output)).toEqual(output); + for (const invalid of [ + group("guidance", "file_writing"), + group("tool_result", "system"), + group("user_role_message", "session_task"), + group("assistant_text", "commentary"), + group("reasoning", "unknown"), + group("unknown", "unknown"), + ]) + expect(() => decodeInput([invalid])).toThrow(); + for (const invalid of [ + group("guidance", "system"), + group("assistant_text", "execution"), + group("tool_call_arguments", "system"), + group("other_generated", "final"), + group("reasoning", "unknown"), + group("unknown", "unknown"), + ]) + expect(() => decodeOutput([invalid])).toThrow(); + }); + + it("requires history scope and indexed selection together while preserving their absence", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReport.fields.provider_coverage); + const coverage = { + selected_requests: 0, + accounting_status_counts: { + primary: 0, + legacy_unresolved: 0, + conflict: 0, + aggregate_delta: 0, + }, + token_missingness: { + input_tokens: 0, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + cache_write_5m_tokens: 0, + cache_write_1h_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + }, + latest_scan: { + captured_at: null, + selected_files: null, + refreshed_selected_files: null, + partial_selected_files: null, + root_scope_ids: [], + mtime_cutoff: null, + receipts_status_counts: null, + validated_files: null, + }, + cumulative_index: { + tracked_files: null, + retained_not_refreshed_files: null, + missing_tracked_files: null, + }, + freshness: { + status: "scan_unavailable", + requested_end: null, + last_scan_captured_at: null, + selected_validation_min: null, + selected_validation_max: null, + unrefreshed_files: null, + }, + historical_window_completeness: "not_proven", + }; + const selection = { + requested_thread_count: 2, + indexed_thread_count: 1, + status: "partially_indexed", + }; + const scoped = { + ...coverage, + history_scope: "all_indexed_history", + indexed_history_selection: selection, + }; + expect(decode(coverage)).toEqual(coverage); + expect(decode(scoped)).toEqual(scoped); + expect(() => decode({ ...coverage, history_scope: "all_indexed_history" })).toThrow(); + expect(() => decode({ ...coverage, indexed_history_selection: selection })).toThrow(); + }); + + it("requires real UTC calendar timestamps including Gregorian leap-year boundaries", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReadResult); + const observation = { + state: "unavailable", + status: "unconfigured", + reason: "reader_unconfigured", + configuredReportId: null, + }; + for (const readAt of [ + "2024-02-29T23:59:59.123456Z", + "2000-02-29T00:00:00Z", + "2026-10-02T12:00:00.123Z", + ]) { + expect(decode({ ...observation, readAt })).toEqual({ ...observation, readAt }); + } + for (const readAt of [ + "1900-02-29T00:00:00Z", + "2026-02-29T00:00:00Z", + "2026-04-31T00:00:00Z", + "2026-00-01T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-01-00T00:00:00Z", + "0000-01-01T00:00:00Z", + "2026-01-01T24:00:00Z", + "2026-01-01T00:60:00Z", + "2026-01-01T00:00:60Z", + "2026-01-01T00:00:00.1234567Z", + "2026-01-01T00:00:00+00:00", + ]) + expect(() => decode({ ...observation, readAt })).toThrow(); + }); + + it("requires an ordered finite window at microsecond precision and preserves indexed history", () => { + const decode = Schema.decodeUnknownSync(TokenAccountingReport.fields.window); + const finite = { + start: "2026-10-02T12:00:00.100001Z", + end: "2026-10-02T12:00:00.100002Z", + end_exclusive: true, + }; + expect(decode(finite)).toEqual(finite); + const history = { scope: "all_indexed_history", start: null, end: null, end_exclusive: false }; + expect(decode(history)).toEqual(history); + for (const invalid of [ + { ...finite, start: finite.end, end: finite.start }, + { ...finite, end: finite.start }, + { ...finite, start: "2026-10-02T12:00:00.1Z", end: "2026-10-02T12:00:00.100000Z" }, + { ...finite, start: "2026-10-02T12:00:00Z", end: "2026-10-02T12:00:00.000000Z" }, + { ...finite, start: null }, + { ...finite, end: "2026-02-30T12:00:00Z" }, + ]) + expect(() => decode(invalid)).toThrow(); + }); +}); diff --git a/packages/contracts/src/tokenAccounting.ts b/packages/contracts/src/tokenAccounting.ts new file mode 100644 index 000000000..a6ac2061f --- /dev/null +++ b/packages/contracts/src/tokenAccounting.ts @@ -0,0 +1,517 @@ +import * as Schema from "effect/Schema"; + +export const TOKEN_ACCOUNTING_MAX_INPUT_BYTES = 2 * 1024 * 1024; +export const TOKEN_ACCOUNTING_MAX_RESPONSE_BYTES = 256 * 1024; +export const TOKEN_ACCOUNTING_MAX_GROUPS = 128; +export const TOKEN_ACCOUNTING_MAX_SOURCE_COLLECTION = 256; +export const TOKEN_ACCOUNTING_PROJECTION_SCHEMA = "jones-code.token-accounting-projection/v1"; +export const TOKEN_ACCOUNTING_REPORT_SCHEMA = "programmatic-token-info.accounting-report/v1"; +export const TOKEN_ACCOUNTING_IDENTITY_ALGORITHM = "programmatic-token-info.binary64-tree/v1"; + +// Input-side checks see undeclared keys that Struct removes from its decoded output. +const closed = (schema: Schema.Struct) => + Schema.flip( + Schema.flip(schema).check( + Schema.makeFilter( + (value) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Reflect.ownKeys(value).every((key) => Object.hasOwn(schema.fields, key)), + ), + ), + ); +const Count = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })); +const TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,6})?Z$/; +function isCalendarUtcTimestamp(value: string): boolean { + const parts = TIMESTAMP_PATTERN.exec(value); + if (parts === null) return false; + const year = Number(parts[1]); + const month = Number(parts[2]); + const day = Number(parts[3]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return ( + year >= 1 && + month >= 1 && + month <= 12 && + day >= 1 && + day <= (daysInMonth[month - 1] ?? 0) && + Number(parts[4]) <= 23 && + Number(parts[5]) <= 59 && + Number(parts[6]) <= 59 + ); +} +const Timestamp = Schema.String.check( + Schema.isMaxLength(32), + Schema.makeFilter(isCalendarUtcTimestamp), +); +const comparableTimestamp = (value: string): string => + value.replace( + /(?:\.(\d{1,6}))?Z$/, + (_match: string, fraction: string | undefined) => `.${(fraction ?? "").padEnd(6, "0")}Z`, + ); +export const TokenAccountingReportId = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); +const boundedGroups = (schema: S) => + Schema.Array(schema).check(Schema.isMaxLength(TOKEN_ACCOUNTING_MAX_GROUPS)); + +export const TokenAccountingMetric = closed( + Schema.Struct({ + known_sum: Schema.NullOr(Count), + total: Schema.NullOr(Count), + known_requests: Count, + missing_requests: Count, + }), +).check( + Schema.makeFilter( + (metric) => + (metric.known_requests === 0 + ? metric.known_sum === null && metric.total === null + : metric.known_sum !== null) && + (metric.total === null || + (metric.missing_requests === 0 && metric.total === metric.known_sum)), + ), +); +export type TokenAccountingMetric = typeof TokenAccountingMetric.Type; + +const tokenMetricFields = { + input_tokens: TokenAccountingMetric, + cached_input_tokens: TokenAccountingMetric, + cache_write_input_tokens: TokenAccountingMetric, + cache_write_5m_tokens: TokenAccountingMetric, + cache_write_1h_tokens: TokenAccountingMetric, + output_tokens: TokenAccountingMetric, + reasoning_output_tokens: TokenAccountingMetric, +}; +const statusCounts = closed( + Schema.Struct({ + primary: Count, + legacy_unresolved: Count, + conflict: Count, + aggregate_delta: Count, + }), +); +const receiptStatusCounts = closed( + Schema.Struct({ + selected_stat_unchanged: Schema.optionalKey(Count), + selected_rebuilt: Schema.optionalKey(Count), + selected_append: Schema.optionalKey(Count), + retained_missing: Schema.optionalKey(Count), + retained_not_selected: Schema.optionalKey(Count), + archive_snapshot: Schema.optionalKey(Count), + }), +); +const providerCoverage = closed( + Schema.Struct({ + selected_requests: Count, + accounting_status_counts: statusCounts, + token_missingness: closed( + Schema.Struct({ + input_tokens: Count, + cached_input_tokens: Count, + cache_write_input_tokens: Count, + cache_write_5m_tokens: Count, + cache_write_1h_tokens: Count, + output_tokens: Count, + reasoning_output_tokens: Count, + }), + ), + latest_scan: closed( + Schema.Struct({ + captured_at: Schema.NullOr(Timestamp), + selected_files: Schema.NullOr(Count), + refreshed_selected_files: Schema.NullOr(Count), + partial_selected_files: Schema.NullOr(Count), + root_scope_ids: Schema.Array(TokenAccountingReportId).check( + Schema.isMaxLength(TOKEN_ACCOUNTING_MAX_SOURCE_COLLECTION), + ), + mtime_cutoff: Schema.NullOr(Timestamp), + receipts_status_counts: Schema.NullOr(receiptStatusCounts), + validated_files: Schema.NullOr(Count), + }), + ), + cumulative_index: closed( + Schema.Struct({ + tracked_files: Schema.NullOr(Count), + retained_not_refreshed_files: Schema.NullOr(Count), + missing_tracked_files: Schema.NullOr(Count), + }), + ), + freshness: closed( + Schema.Struct({ + status: Schema.Literals([ + "scan_unavailable", + "archive_snapshot_only", + "validation_receipts_unavailable", + "no_selected_source_validated", + "requested_end_unspecified", + "requested_end_after_selected_validation", + "retained_sources_not_refreshed", + "selected_sources_validated_after_requested_end", + ]), + requested_end: Schema.NullOr(Timestamp), + last_scan_captured_at: Schema.NullOr(Timestamp), + selected_validation_min: Schema.NullOr(Timestamp), + selected_validation_max: Schema.NullOr(Timestamp), + unrefreshed_files: Schema.NullOr(Count), + }), + ), + historical_window_completeness: Schema.Literal("not_proven"), + history_scope: Schema.optionalKey(Schema.Literal("all_indexed_history")), + indexed_history_selection: Schema.optionalKey( + closed( + Schema.Struct({ + requested_thread_count: Count, + indexed_thread_count: Count, + status: Schema.Literals(["no_indexed_requests", "partially_indexed", "indexed"]), + }), + ), + ), + }), +).check( + Schema.makeFilter( + (coverage) => + (coverage.history_scope === undefined) === (coverage.indexed_history_selection === undefined), + ), +); +const window = Schema.Union([ + closed( + Schema.Struct({ start: Timestamp, end: Timestamp, end_exclusive: Schema.Literal(true) }), + ).check( + Schema.makeFilter((value) => comparableTimestamp(value.start) < comparableTimestamp(value.end)), + ), + closed( + Schema.Struct({ + scope: Schema.Literal("all_indexed_history"), + start: Schema.Null, + end: Schema.Null, + end_exclusive: Schema.Literal(false), + }), + ), +]); +const snapshot = closed( + Schema.Struct({ + index_snapshot_id: TokenAccountingReportId, + source_revision: Schema.optionalKey(TokenAccountingReportId), + mapping_revision: Schema.optionalKey(TokenAccountingReportId), + scan_id: Schema.optionalKey(TokenAccountingReportId), + captured_at: Schema.optionalKey(Timestamp), + schema_version: Schema.optionalKey(Schema.Literal("programmatic-token-info.index/v1")), + source_validation: Schema.optionalKey( + Schema.Literals(["provider_append_only_stat_and_anchor", "archive_snapshot_only"]), + ), + }), +); +const allocationBandFields = { + basis: Schema.Literals(["measured_component", "estimated_calibrated"]), + central: Count, + low: Count, + high: Count, +}; +const inputAllocationGroup = Schema.Union([ + closed( + Schema.Struct({ + group: Schema.Literals([ + "user_role_message", + "tool_schema_config", + "client_control", + "nontext_input", + ]), + subtype: Schema.Literal("unknown"), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("guidance"), + subtype: Schema.Literals([ + "base_instructions", + "developer", + "system", + "session_task", + "unknown", + ]), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("tool_result"), + subtype: Schema.Literals([ + "execution", + "file_read_code", + "file_read_document", + "search", + "build_test", + "vcs", + "web", + "mcp", + "agent", + "patch", + "other", + "unknown", + ]), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("assistant_history"), + subtype: Schema.Literals(["assistant_text", "tool_call_arguments", "unknown"]), + ...allocationBandFields, + }), + ), +]); +const outputAllocationGroup = Schema.Union([ + closed( + Schema.Struct({ + group: Schema.Literal("assistant_text"), + subtype: Schema.Literals(["final", "commentary", "unphased", "unknown"]), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("tool_call_arguments"), + subtype: Schema.Literals(["file_writing", "agent_launch", "other", "unknown"]), + ...allocationBandFields, + }), + ), + closed( + Schema.Struct({ + group: Schema.Literal("other_generated"), + subtype: Schema.Literal("unknown"), + ...allocationBandFields, + }), + ), +]); +const unknownByReason = closed( + Schema.Struct({ + missing_counter: Schema.optionalKey(TokenAccountingMetric), + source_unavailable: Schema.optionalKey(TokenAccountingMetric), + ambiguous_join: Schema.optionalKey(TokenAccountingMetric), + estimator_unavailable: Schema.optionalKey(TokenAccountingMetric), + cache_placement_unsupported: Schema.optionalKey(TokenAccountingMetric), + envelope_incomplete: Schema.optionalKey(TokenAccountingMetric), + incompatible_scenario: Schema.optionalKey(TokenAccountingMetric), + outside_estimator_domain: Schema.optionalKey(TokenAccountingMetric), + nontext_unqualified: Schema.optionalKey(TokenAccountingMetric), + framing_or_hidden_unknown: Schema.optionalKey(TokenAccountingMetric), + }), +); +const allocationFields = { + allocated: TokenAccountingMetric, + unknown: TokenAccountingMetric, + unknown_by_reason: unknownByReason, + estimated_coverage: Schema.NullOr( + Schema.Number.check(Schema.isFinite(), Schema.isBetween({ minimum: 0, maximum: 1 })), + ), +}; +const partition = closed( + Schema.Struct({ + requests: Count, + metrics: closed(Schema.Struct({ ...tokenMetricFields, ordinary_input: TokenAccountingMetric })), + }), +); +export const TOKEN_ACCOUNTING_CAVEATS = [ + "primary_requests_only_are_additive", + "missing_counters_leave_totals_unknown", + "visible_inventory_is_not_submitted_payload", + "user_role_does_not_prove_owner_speech", + "candidate_envelope_is_unproved_submission", + "cache_placement_unsupported", + "unknown_residuals_are_not_zero", + "reasoning_is_an_output_subset", + "mechanism_flags_are_nonadditive", + "counter_changes_are_diagnostics_only", + "bands_use_held_out_group_bias", + "historical_window_completeness_not_proven", + "append_only_source_validation_limit", + "partial_counter_masks_limit_aggregate_conservation", +] as const; + +/** This is a bounded projection; Python validates the original report identity and conservation. */ +export const TokenAccountingReport = closed( + Schema.Struct({ + schema: Schema.Literal(TOKEN_ACCOUNTING_PROJECTION_SCHEMA), + source_schema: Schema.Literal(TOKEN_ACCOUNTING_REPORT_SCHEMA), + report_id: TokenAccountingReportId, + selection_id: TokenAccountingReportId, + source_identity_algorithm: Schema.Literal(TOKEN_ACCOUNTING_IDENTITY_ALGORITHM), + source_identity_verified: Schema.Literal(true), + snapshot, + window, + provider_coverage: providerCoverage, + provider_ledger: closed( + Schema.Struct({ + requests: Count, + primary_requests: Count, + nonadditive_requests: Count, + accounting_status_counts: statusCounts, + metrics: closed( + Schema.Struct({ + ...tokenMetricFields, + ordinary_input_tokens: TokenAccountingMetric, + non_read_input_tokens: TokenAccountingMetric, + }), + ), + }), + ), + input: closed( + Schema.Struct({ + ordinary_input: TokenAccountingMetric, + groups: boundedGroups(inputAllocationGroup), + ...allocationFields, + }), + ), + output: closed( + Schema.Struct({ + output: TokenAccountingMetric, + measured_reasoning: TokenAccountingMetric, + groups: boundedGroups(outputAllocationGroup), + ...allocationFields, + }), + ), + partitions: closed( + Schema.Struct({ + provider: closed(Schema.Struct({ codex: partition, claude: partition })), + role: closed(Schema.Struct({ root: partition, child: partition, unknown: partition })), + }), + ), + mechanism_flags: closed( + Schema.Struct({ + nonadditive: Schema.Literal(true), + counts: closed( + Schema.Struct({ + new_content: Schema.optionalKey(Count), + repeated_history: Schema.optionalKey(Count), + changed_guidance_schema_prefix: Schema.optionalKey(Count), + compaction_restart: Schema.optionalKey(Count), + first_observation_or_missing_predecessor: Schema.optionalKey(Count), + cache_miss_unchanged_reconstruction: Schema.optionalKey(Count), + mixed_unknown: Schema.optionalKey(Count), + }), + ), + }), + ), + estimator_status_counts: closed( + Schema.Struct({ qualified: Count, unavailable: Count, failed: Count }), + ), + coverage: closed( + Schema.Struct({ + primary_requests: Count, + captured_requests: Count, + unavailable_requests: Count, + complete_response_requests: Count, + input_allocated_requests: Count, + output_allocated_requests: Count, + reasoning_measured_requests: Count, + reasoning_missing_requests: Count, + estimator_qualified_models: Count, + estimator_unavailable_models: Count, + diagnostics: closed( + Schema.Struct({ + requests_output_without_generated_components: Schema.optionalKey(Count), + }), + ), + }), + ), + caveats: Schema.Array(Schema.Literals(TOKEN_ACCOUNTING_CAVEATS)).check( + Schema.makeFilter( + (values) => + values.length === TOKEN_ACCOUNTING_CAVEATS.length && + values.every((value, index) => value === TOKEN_ACCOUNTING_CAVEATS[index]), + ), + ), + authority_effect: Schema.Literal("none"), + }), +).check( + Schema.makeFilter( + (report) => + report.estimator_status_counts.qualified === report.coverage.estimator_qualified_models && + report.estimator_status_counts.unavailable + report.estimator_status_counts.failed === + report.coverage.estimator_unavailable_models, + ), +); +export type TokenAccountingReport = typeof TokenAccountingReport.Type; + +export const TokenAccountingReadInput = closed(Schema.Struct({})); +export type TokenAccountingReadInput = typeof TokenAccountingReadInput.Type; + +const unavailableVariants = [ + closed( + Schema.Struct({ + status: Schema.Literal("unconfigured"), + reason: Schema.Literals([ + "reader_unconfigured", + "report_unconfigured", + "host_binding_unverified", + ]), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("missing"), + reason: Schema.Literal("configured_report_missing"), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("invalid"), + reason: Schema.Literals([ + "report_invalid", + "report_identity_mismatch", + "configured_report_id_mismatch", + "projection_invalid", + ]), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("unsupported"), + reason: Schema.Literals(["report_schema_unsupported", "identity_algorithm_unsupported"]), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("oversized"), + reason: Schema.Literals([ + "input_too_large", + "projection_too_large", + "collection_limit_exceeded", + ]), + }), + ), + closed( + Schema.Struct({ + status: Schema.Literal("reader_failed"), + reason: Schema.Literals(["reader_timeout", "reader_failed"]), + }), + ), +] as const; +export const TokenAccountingUnavailable = Schema.Union(unavailableVariants); +export type TokenAccountingUnavailable = typeof TokenAccountingUnavailable.Type; + +const observationFields = { + configuredReportId: Schema.NullOr(TokenAccountingReportId), + readAt: Timestamp, +}; +export const TokenAccountingReadResult = Schema.Union([ + closed( + Schema.Struct({ + state: Schema.Literal("ready"), + report: TokenAccountingReport, + readAt: Timestamp, + }), + ), + ...unavailableVariants.map((variant) => + closed( + Schema.Struct({ + state: Schema.Literal("unavailable"), + ...variant.fields, + ...observationFields, + }), + ), + ), +]); +export type TokenAccountingReadResult = typeof TokenAccountingReadResult.Type; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index f6282b7b2..bc31bc516 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -54,7 +54,7 @@ export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_P ); export type UsageDay = typeof UsageDay.Type; -export const UsageResolution = Schema.Literals(["day", "hour"]); +export const UsageResolution = Schema.Literals(["day", "hour", "exactDay"]); export type UsageResolution = typeof UsageResolution.Type; /** @@ -211,11 +211,11 @@ export const UsageSummaryInput = Schema.Struct({ * any window that crosses a DST boundary. */ timeZone: TrimmedNonEmptyString, - /** Defaults to daily for older clients. */ + /** Defaults to day; `exactDay` filters by paired instants and keeps day buckets. */ resolution: Schema.optional(UsageResolution), - /** Inclusive UTC instant for an hourly rolling window. */ + /** Inclusive UTC instant for hourly or exact-day windows. */ sinceTime: Schema.optional(TrimmedNonEmptyString), - /** Exclusive UTC instant for an hourly rolling window. */ + /** Exclusive UTC instant for hourly or exact-day windows. */ untilTime: Schema.optional(TrimmedNonEmptyString), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index dec0e46b6..55a790ebe 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -273,6 +273,19 @@ export class VcsUnsupportedOperationError extends Schema.TaggedError()( + "VcsPrimaryCheckoutCheckpointError", + { + operation: Schema.String, + cwd: Schema.String, + }, +) { + override get message(): string { + return `VCS checkpoint capture refused in ${this.operation}: ${this.cwd} is Git's primary checkout.`; + } +} + export const VcsError = Schema.Union([ VcsProcessSpawnError, VcsProcessExitError, @@ -283,5 +296,6 @@ export const VcsError = Schema.Union([ VcsProcessMissingExitCodeError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, + VcsPrimaryCheckoutCheckpointError, ]); export type VcsError = typeof VcsError.Type; diff --git a/packages/contracts/src/voiceReview.test.ts b/packages/contracts/src/voiceReview.test.ts new file mode 100644 index 000000000..a4eaf8ba6 --- /dev/null +++ b/packages/contracts/src/voiceReview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { + VoiceReviewRevisionPayload, + VoiceReviewEditSavePayload, + VoiceReviewEditCancelPayload, +} from "./voiceReview.ts"; + +const decodeRevision = Schema.decodeUnknownSync(VoiceReviewRevisionPayload); +const decodeEditSave = Schema.decodeUnknownSync(VoiceReviewEditSavePayload); +const decodeEditCancel = Schema.decodeUnknownSync(VoiceReviewEditCancelPayload); + +describe("voice review mutation wire contract", () => { + it("rejects coercion, nonpositive revisions, and extra fields", () => { + const decode = decodeRevision; + expect(decode({ expected_revision: 1 })).toEqual({ expected_revision: 1 }); + for (const value of [true, false, "1", 0, -1, 1.5, null]) { + expect(() => decode({ expected_revision: value })).toThrow(); + } + expect(() => decode({ expected_revision: 1, source_id: "other" })).toThrow(); + expect(() => decode({ expected_revision: 1, edit_handle: "secret" })).toThrow(); + }); + it("requires an edit handle and preserves literal text with the 100000 character ceiling", () => { + const decode = decodeEditSave; + const valid = { + expected_revision: 2, + edit_handle: "opaque", + text: " ", + }; + expect(decode(valid)).toEqual(valid); + expect(() => decode({ ...valid, text: " \n " })).toThrow(); + expect(() => decode({ ...valid, text: "a".repeat(100_001) })).toThrow(); + expect(() => decode({ ...valid, edit_handle: "" })).toThrow(); + expect(() => decodeEditCancel({ expected_revision: 1 })).toThrow(); + }); +}); diff --git a/packages/contracts/src/voiceReview.ts b/packages/contracts/src/voiceReview.ts new file mode 100644 index 000000000..2dbd1cbb1 --- /dev/null +++ b/packages/contracts/src/voiceReview.ts @@ -0,0 +1,139 @@ +import * as Schema from "effect/Schema"; +import { NonNegativeInt, PositiveInt, IsoDateTime } from "./baseSchemas.ts"; + +export const VoiceReviewState = Schema.Literals([ + "held", + "paused", + "editing", + "blocked", + "released", + "deleted", + "expired", +]); +export type VoiceReviewState = typeof VoiceReviewState.Type; +export const VoiceReviewReason = Schema.Literals([ + "source_unavailable", + "grant_denied", + "backend_unavailable", + "capacity", +]); +export const VoiceReviewText = Schema.String.check( + Schema.isMaxLength(100_000), + Schema.isPattern(/\S/), +); +export const VoiceReviewDraft = Schema.Struct({ + id: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), + source_id: Schema.String, + state: VoiceReviewState, + revision: PositiveInt, + text: Schema.NullOr(VoiceReviewText), + transcript_provider: Schema.String, + language: Schema.NullOr(Schema.String), + edited: Schema.Boolean, + created_at: IsoDateTime, + updated_at: IsoDateTime, + due_at: Schema.NullOr(IsoDateTime), + remaining_ms: Schema.NullOr(NonNegativeInt), + expires_at: IsoDateTime, + command_id: Schema.NullOr(Schema.String), + command_status: Schema.NullOr(Schema.String), + reason: Schema.NullOr(VoiceReviewReason), + server_now: IsoDateTime, +}); +export type VoiceReviewDraft = typeof VoiceReviewDraft.Type; +export const VoiceReviewDraftList = Schema.Struct({ + server_now: IsoDateTime, + drafts: Schema.Array(VoiceReviewDraft).check(Schema.isMaxLength(200)), +}); +export type VoiceReviewDraftList = typeof VoiceReviewDraftList.Type; +export const VoiceReviewMutationResult = Schema.Struct({ + draft: VoiceReviewDraft, + edit_handle: Schema.NullOr(Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(4096))), +}); +export type VoiceReviewMutationResult = typeof VoiceReviewMutationResult.Type; +export const VoiceReviewAction = Schema.Literals([ + "pause", + "play", + "edit-begin", + "edit-save", + "edit-cancel", + "send-now", + "delete", +]); +export type VoiceReviewAction = typeof VoiceReviewAction.Type; +const strictPayload = (fields: Fields) => + Schema.Record(Schema.String, Schema.Unknown) + .check(Schema.isPropertyNames(Schema.Literals(Object.keys(fields)))) + .pipe(Schema.decodeTo(Schema.Struct(fields))); + +export const VoiceReviewRevisionPayload = strictPayload({ + expected_revision: PositiveInt, +}); +export const VoiceReviewEditSavePayload = strictPayload({ + expected_revision: PositiveInt, + text: VoiceReviewText, + edit_handle: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(4096)), +}); +export const VoiceReviewEditCancelPayload = strictPayload({ + expected_revision: PositiveInt, + edit_handle: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(4096)), +}); +export type VoiceReviewMutationPayload = + | typeof VoiceReviewRevisionPayload.Type + | typeof VoiceReviewEditSavePayload.Type + | typeof VoiceReviewEditCancelPayload.Type; + +export class VoiceReviewNotConfiguredError extends Schema.TaggedError()( + "VoiceReviewNotConfiguredError", + {}, + { httpApiStatus: 503 }, +) { + override get message() { + return "Voice review is not configured for this environment."; + } +} +export class VoiceReviewForbiddenError extends Schema.TaggedError()( + "VoiceReviewForbiddenError", + {}, + { httpApiStatus: 403 }, +) { + override get message() { + return "This session cannot access voice review."; + } +} +export class VoiceReviewNotFoundError extends Schema.TaggedError()( + "VoiceReviewNotFoundError", + {}, + { httpApiStatus: 404 }, +) { + override get message() { + return "The voice draft is unavailable."; + } +} +export class VoiceReviewConflictError extends Schema.TaggedError()( + "VoiceReviewConflictError", + {}, + { httpApiStatus: 409 }, +) { + override get message() { + return "The voice draft changed. Refresh it before acting again."; + } +} +export class VoiceReviewUnavailableError extends Schema.TaggedError()( + "VoiceReviewUnavailableError", + {}, + { httpApiStatus: 502 }, +) { + override get message() { + return "Voice review is unavailable. Refresh the draft to observe its current state."; + } +} +export const VoiceReviewErrors = [ + VoiceReviewNotConfiguredError, + VoiceReviewForbiddenError, + VoiceReviewNotFoundError, + VoiceReviewConflictError, + VoiceReviewUnavailableError, +] as const; +export const VoiceReviewError = Schema.Union(VoiceReviewErrors); +export type VoiceReviewError = typeof VoiceReviewError.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index b67a205e5..17bb2f5aa 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./conversationLibrary": { + "types": "./src/conversationLibrary.ts", + "import": "./src/conversationLibrary.ts" + }, "./orchestrationV2ThreadError": { "types": "./src/orchestrationV2ThreadError.ts", "import": "./src/orchestrationV2ThreadError.ts" diff --git a/packages/shared/src/conversationLibrary.cases.ts b/packages/shared/src/conversationLibrary.cases.ts new file mode 100644 index 000000000..0b6248145 --- /dev/null +++ b/packages/shared/src/conversationLibrary.cases.ts @@ -0,0 +1,317 @@ +import * as NodeAssert from "node:assert/strict"; +import type { ExportConversation } from "@t3tools/contracts/conversationLibrary"; +import { + LibraryRequestFence, + libraryBranch, + libraryBranchIds, + libraryOriginalUrl, + libraryRequestMutates, + normalizeConversationExport, +} from "./conversationLibrary.ts"; + +function sampleConversation(): ExportConversation { + return { + id: "chat-one", + title: "A sample conversation", + update_time: 1_700_000_000, + current_node: "a", + mapping: { + root: { id: "root", parent: null, message: null }, + u: { + id: "u", + parent: "root", + message: { + id: "user-id", + author: { role: "user" }, + content: { content_type: "text", parts: ["Question"] }, + }, + }, + a: { + id: "a", + parent: "u", + message: { + id: "answer-id", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Answer"] }, + }, + }, + b: { + id: "b", + parent: "u", + message: { + id: "other-answer-id", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Regenerated answer"] }, + }, + }, + }, + }; +} + +export const conversationLibraryCases: readonly { + readonly name: string; + readonly run: () => void; +}[] = [ + { + name: "preserves the selected path and regenerated alternatives", + run: () => { + const snapshot = normalizeConversationExport([sampleConversation()])[0]!; + NodeAssert.deepEqual( + libraryBranch(snapshot.nodes, snapshot.currentNodeId).map((n) => n.text), + ["Question", "Answer"], + ); + NodeAssert.deepEqual( + libraryBranch(snapshot.nodes, "b").map((n) => n.text), + ["Question", "Regenerated answer"], + ); + NodeAssert.deepEqual(libraryBranchIds(snapshot.nodes, snapshot.currentNodeId), ["a", "b"]); + }, + }, + { + name: "canonicalizes mapping order without merging distinct nodes", + run: () => { + const sample = sampleConversation(); + const reordered = { + ...sample, + mapping: Object.fromEntries(Object.entries(sample.mapping).reverse()), + }; + NodeAssert.equal( + JSON.stringify(normalizeConversationExport([sample])), + JSON.stringify(normalizeConversationExport([reordered])), + ); + }, + }, + { + name: "rejects a cycle outside the selected path", + run: () => { + const sample = sampleConversation(); + const mapping = { ...sample.mapping, x: { parent: "y" }, y: { parent: "x" } }; + NodeAssert.throws(() => normalizeConversationExport([{ ...sample, mapping }]), /cycle/); + }, + }, + { + name: "retains incomplete branches with an explicit gap", + run: () => { + const sample = sampleConversation(); + const mapping = { ...sample.mapping, u: { ...sample.mapping.u!, parent: "missing" } }; + const snapshot = normalizeConversationExport([{ ...sample, mapping }])[0]!; + NodeAssert.match(snapshot.warnings.join(" "), /history gap/); + NodeAssert.equal(libraryBranch(snapshot.nodes, "a").length, 2); + }, + }, + { + name: "does not choose another branch when the exported selection is missing", + run: () => { + const snapshot = normalizeConversationExport([ + { ...sampleConversation(), current_node: "unknown" }, + ])[0]!; + NodeAssert.equal(snapshot.currentNodeId, null); + NodeAssert.deepEqual(libraryBranch(snapshot.nodes, null), []); + NodeAssert.throws(() => libraryBranch(snapshot.nodes, "unknown"), /not in this snapshot/); + }, + }, + { + name: "reports unsupported image and tool parts without extracting their URLs", + run: () => { + const snapshot = normalizeConversationExport([ + { + id: "media", + title: "Media", + current_node: "one", + mapping: { + one: { + message: { + author: { role: "assistant" }, + content: { + parts: ["Visible text", { asset_pointer: "https://example.invalid/private" }], + }, + }, + }, + }, + }, + ])[0]!; + NodeAssert.equal(snapshot.nodes[0]!.text, "Visible text"); + NodeAssert.equal(snapshot.nodes[0]!.unsupportedParts, 1); + NodeAssert.match(snapshot.warnings.join(" "), /non-text parts/); + NodeAssert.ok(!JSON.stringify(snapshot).includes("private")); + }, + }, + { + name: "keeps hidden exported messages without displaying them by default", + run: () => { + const snapshot = normalizeConversationExport([ + { + id: "hidden", + title: "Hidden", + current_node: "one", + mapping: { + one: { + message: { + author: { role: "system" }, + metadata: { is_visually_hidden_from_conversation: true }, + content: { parts: ["Hidden text"] }, + }, + }, + }, + }, + ])[0]!; + NodeAssert.equal(libraryBranch(snapshot.nodes, "one").length, 0); + NodeAssert.equal(libraryBranch(snapshot.nodes, "one", true).length, 1); + }, + }, + { + name: "rejects duplicate conversation identities in a batch", + run: () => { + NodeAssert.throws( + () => normalizeConversationExport([sampleConversation(), sampleConversation()]), + /twice/, + ); + }, + }, + { + name: "rejects contradictory aliases and mismatched mapping IDs", + run: () => { + NodeAssert.throws( + () => normalizeConversationExport([{ ...sampleConversation(), conversation_id: "other" }]), + /conflicting/, + ); + NodeAssert.throws( + () => + normalizeConversationExport([ + { ...sampleConversation(), mapping: { key: { id: "different" } } }, + ]), + /mapping key/, + ); + }, + }, + { + name: "never normalizes whitespace into a different identity", + run: () => { + NodeAssert.throws( + () => normalizeConversationExport([{ ...sampleConversation(), id: " chat-one" }]), + /whitespace/, + ); + NodeAssert.throws( + () => + normalizeConversationExport([ + { ...sampleConversation(), mapping: { " a": { parent: null } } }, + ]), + /whitespace/, + ); + }, + }, + { + name: "validates timestamps instead of substituting import time", + run: () => { + NodeAssert.throws( + () => normalizeConversationExport([{ ...sampleConversation(), update_time: Infinity }]), + /timestamp/, + ); + NodeAssert.equal( + normalizeConversationExport([{ ...sampleConversation(), update_time: null }])[0]! + .sourceUpdatedAt, + null, + ); + }, + }, + { + name: "bounds node count and text bytes before admission", + run: () => { + NodeAssert.throws( + () => + normalizeConversationExport([ + { + ...sampleConversation(), + mapping: Object.fromEntries( + Array.from({ length: 10_001 }, (_, n) => [String(n), { parent: null }]), + ), + }, + ]), + /exceeds/, + ); + const big = "x".repeat(8 * 1024 * 1024 + 1); + NodeAssert.throws( + () => + normalizeConversationExport([ + { + ...sampleConversation(), + mapping: { + a: { message: { author: { role: "assistant" }, content: { parts: [big] } } }, + }, + }, + ]), + /text budget/, + ); + }, + }, + { + name: "constructs only fixed-origin original-chat links", + run: () => { + NodeAssert.equal(libraryOriginalUrl("chat-one"), "https://chatgpt.com/c/chat-one"); + for (const id of ["../logout", "https://evil.invalid", "a?token=secret", "a#x", ""]) + NodeAssert.equal(libraryOriginalUrl(id), null); + }, + }, + { + name: "invalidates delayed replies when the selected context changes", + run: () => { + const fence = new LibraryRequestFence(); + const accountA = fence.next(); + const accountB = fence.next(); + NodeAssert.equal(fence.accepts(accountA), false); + NodeAssert.equal(fence.accepts(accountB), true); + fence.next(); + NodeAssert.equal(fence.accepts(accountB), false); + }, + }, + { + name: "requires mutation authority for every state-changing request", + run: () => { + NodeAssert.equal( + libraryRequestMutates({ kind: "import", accountId: "a", conversations: [] }), + true, + ); + NodeAssert.equal( + libraryRequestMutates({ kind: "createAccount", label: "a", workspace: "Personal" }), + true, + ); + NodeAssert.equal(libraryRequestMutates({ kind: "update", key: "a", pinned: true }), true); + NodeAssert.equal( + libraryRequestMutates({ + kind: "selectSnapshot", + key: "a", + snapshotId: "b", + expectedRevision: 1, + }), + true, + ); + NodeAssert.equal( + libraryRequestMutates({ kind: "remove", key: "a", expectedRevision: 1 }), + true, + ); + for (const request of [ + { kind: "hello" }, + { kind: "accounts" }, + { kind: "list" }, + { kind: "detail", key: "a" }, + { kind: "preview", conversations: [] }, + ] as const) + NodeAssert.equal(libraryRequestMutates(request), false); + }, + }, + { + name: "handles prototype-looking node IDs as ordinary map keys", + run: () => { + const mapping = Object.fromEntries([ + [ + "__proto__", + { parent: null, message: { author: { role: "user" }, content: { parts: ["Safe"] } } }, + ], + ]); + const snapshot = normalizeConversationExport([ + { id: "prototype", title: "Prototype", current_node: "__proto__", mapping }, + ])[0]!; + NodeAssert.equal(libraryBranch(snapshot.nodes, "__proto__")[0]!.text, "Safe"); + }, + }, +]; diff --git a/packages/shared/src/conversationLibrary.test.ts b/packages/shared/src/conversationLibrary.test.ts new file mode 100644 index 000000000..8d250b861 --- /dev/null +++ b/packages/shared/src/conversationLibrary.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "vite-plus/test"; +import { conversationLibraryCases } from "./conversationLibrary.cases.ts"; + +describe("conversation library", () => { + it.each(conversationLibraryCases)("$name", ({ run }) => run()); +}); diff --git a/packages/shared/src/conversationLibrary.ts b/packages/shared/src/conversationLibrary.ts new file mode 100644 index 000000000..d8cc38267 --- /dev/null +++ b/packages/shared/src/conversationLibrary.ts @@ -0,0 +1,243 @@ +import { + LIBRARY_MAX_CONVERSATIONS, + LIBRARY_MAX_NODES, + LIBRARY_MAX_TEXT_BYTES, + type ExportConversation, + type LibraryNode, + type LibrarySnapshot, + type LibraryErrorCode, + type LibraryRequest, +} from "@t3tools/contracts/conversationLibrary"; + +export class ConversationLibraryError extends Error { + readonly code: LibraryErrorCode; + + constructor(code: LibraryErrorCode, message: string) { + super(message); + this.code = code; + this.name = "ConversationLibraryError"; + } +} + +export function boundedLibraryString(value: string, name: string, max = 512): string { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > max || /[\u0000-\u001f\u007f]/.test(trimmed)) { + throw new ConversationLibraryError( + "invalid", + `${name} is empty, too long, or contains control characters.`, + ); + } + return trimmed; +} + +function identifier(value: string, name: string): string { + const checked = boundedLibraryString(value, name); + if (checked !== value) + throw new ConversationLibraryError("invalid", `${name} contains surrounding whitespace.`); + return value; +} + +function exportTime(value: number | null | undefined): number | null { + if (value === undefined || value === null) return null; + const milliseconds = value * 1_000; + if (!Number.isFinite(milliseconds) || Math.abs(milliseconds) > 8_640_000_000_000_000) { + throw new ConversationLibraryError("invalid", "An export contains an invalid timestamp."); + } + return Math.trunc(milliseconds); +} + +function compareIds(a: { readonly id: string }, b: { readonly id: string }): number { + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** Structural decoding belongs at the boundary; this checks graph and size invariants. */ +export function normalizeConversationExport( + input: readonly ExportConversation[], +): readonly LibrarySnapshot[] { + if (input.length === 0 || input.length > LIBRARY_MAX_CONVERSATIONS) { + throw new ConversationLibraryError( + "too-large", + `Import between 1 and ${LIBRARY_MAX_CONVERSATIONS} conversations at a time.`, + ); + } + const ids = new Set(); + const encoder = new TextEncoder(); + let totalBytes = 0; + let totalNodes = 0; + return input.map((conversation) => { + const id = identifier(conversation.id ?? conversation.conversation_id ?? "", "Conversation ID"); + if ( + conversation.id && + conversation.conversation_id && + conversation.id !== conversation.conversation_id + ) { + throw new ConversationLibraryError( + "invalid", + "The export contains conflicting conversation IDs.", + ); + } + if (ids.has(id)) + throw new ConversationLibraryError( + "conflict", + "The same conversation occurs twice in one import. Import the snapshots separately.", + ); + ids.add(id); + const title = boundedLibraryString( + conversation.title || "Untitled conversation", + "Title", + 2_000, + ); + const entries = Object.entries(conversation.mapping); + totalNodes += entries.length; + if (totalNodes > 20_000) + throw new ConversationLibraryError("too-large", "Import at most 20,000 nodes at a time."); + if (entries.length > LIBRARY_MAX_NODES) { + throw new ConversationLibraryError( + "too-large", + `A conversation exceeds ${LIBRARY_MAX_NODES} nodes.`, + ); + } + const nodes = entries + .map(([key, entry]): LibraryNode => { + identifier(key, "Node ID"); + if (entry.id !== undefined && entry.id !== key) { + throw new ConversationLibraryError( + "invalid", + "A node ID differs from its export mapping key.", + ); + } + const message = entry.message; + const content = message?.content; + const parts = content?.parts ?? []; + const textParts = parts.filter((part): part is string => typeof part === "string"); + const text = textParts.length > 0 ? textParts.join("\n") : (content?.text ?? ""); + const unsupportedParts = + parts.length - + textParts.length + + (content && !parts.length && !content.text && content.content_type !== "text" ? 1 : 0); + totalBytes += encoder.encode(text).byteLength; + if (totalBytes > LIBRARY_MAX_TEXT_BYTES) { + throw new ConversationLibraryError( + "too-large", + "The import exceeds the text budget. Split the supplied export into smaller imports.", + ); + } + return { + id: key, + parentId: entry.parent == null ? null : identifier(entry.parent, "Parent ID"), + messageId: message?.id == null ? null : identifier(message.id, "Message ID"), + role: message ? boundedLibraryString(message.author.role, "Author role", 128) : null, + text, + createdAt: exportTime(message?.create_time), + hidden: message?.metadata?.is_visually_hidden_from_conversation === true, + unsupportedParts, + }; + }) + .sort(compareIds); + const byId = new Map(nodes.map((node) => [node.id, node])); + const finished = new Set(); + let missingParents = 0; + for (const node of nodes) { + if (node.parentId !== null && !byId.has(node.parentId)) missingParents++; + const visiting = new Set(); + let cursor: LibraryNode | undefined = node; + while (cursor && !finished.has(cursor.id)) { + if (visiting.has(cursor.id)) + throw new ConversationLibraryError( + "invalid", + "The export contains a cycle in its conversation graph.", + ); + visiting.add(cursor.id); + cursor = cursor.parentId === null ? undefined : byId.get(cursor.parentId); + } + for (const visited of visiting) finished.add(visited); + } + const currentNodeId = + conversation.current_node && byId.has(conversation.current_node) + ? conversation.current_node + : null; + const warnings: string[] = []; + if (missingParents) + warnings.push( + `${missingParents} parent references are absent; those branches have a history gap.`, + ); + if (conversation.current_node && !currentNodeId) + warnings.push("The exported selected node is absent. Select a retained branch explicitly."); + if (!conversation.current_node && nodes.length) + warnings.push( + "The export does not identify its selected branch. Select a retained branch explicitly.", + ); + const unsupported = nodes.reduce((sum, node) => sum + node.unsupportedParts, 0); + if (unsupported) + warnings.push( + `${unsupported} non-text parts are represented as unavailable, not downloaded or executed.`, + ); + return { + conversationId: id, + title, + sourceUpdatedAt: exportTime(conversation.update_time), + currentNodeId, + nodes, + warnings, + }; + }); +} + +export function libraryBranchIds( + nodes: readonly LibraryNode[], + selected: string | null, +): readonly string[] { + const parents = new Set(nodes.flatMap((node) => (node.parentId === null ? [] : [node.parentId]))); + const leaves = nodes.filter((node) => !parents.has(node.id)).map((node) => node.id); + return selected && !leaves.includes(selected) ? [selected, ...leaves] : leaves; +} + +/** An explicit node selection never falls back to some other branch. */ +export function libraryBranch( + nodes: readonly LibraryNode[], + nodeId: string | null, + showHidden = false, +): readonly LibraryNode[] { + if (nodeId === null) return []; + const byId = new Map(nodes.map((node) => [node.id, node])); + if (!byId.has(nodeId)) + throw new ConversationLibraryError("not-found", "The selected branch is not in this snapshot."); + const seen = new Set(); + const path: LibraryNode[] = []; + let cursor = byId.get(nodeId); + while (cursor) { + if (seen.has(cursor.id)) + throw new ConversationLibraryError("invalid", "The retained graph contains a cycle."); + seen.add(cursor.id); + if (cursor.role !== null && (showHidden || !cursor.hidden)) path.push(cursor); + cursor = cursor.parentId === null ? undefined : byId.get(cursor.parentId); + } + return path.reverse(); +} + +export function libraryRequestMutates(request: LibraryRequest): boolean { + return ( + request.kind === "createAccount" || + request.kind === "import" || + request.kind === "update" || + request.kind === "selectSnapshot" || + request.kind === "remove" + ); +} + +export function libraryOriginalUrl(conversationId: string): string | null { + return /^[A-Za-z0-9_-]{1,512}$/.test(conversationId) + ? `https://chatgpt.com/c/${encodeURIComponent(conversationId)}` + : null; +} + +/** Reusing an epoch is a rendering bug: account and page changes invalidate pending reads. */ +export class LibraryRequestFence { + private generation = 0; + next(): number { + return ++this.generation; + } + accepts(generation: number): boolean { + return this.generation === generation; + } +} diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 36e1b2a02..2a45d49a7 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -9,9 +9,44 @@ import { normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, parseOriginUrlFromGitConfig, + resolveDefaultWorktreeBaseBranch, WORKTREE_BRANCH_PREFIX, } from "./git.ts"; +describe("resolveDefaultWorktreeBaseBranch", () => { + const current = { name: "feature/work", current: true, isDefault: false, isRemote: false }; + + it.each(["main", "develop", "origin/release/stable"])( + "prefers default %s over the current branch", + (name) => { + expect( + resolveDefaultWorktreeBaseBranch([ + current, + { name, current: false, isDefault: true, isRemote: name.startsWith("origin/") }, + ]), + ).toBe(name); + }, + ); + + it("uses the current local branch for a local-only repository", () => { + expect(resolveDefaultWorktreeBaseBranch([current])).toBe("feature/work"); + }); + + it("uses a known default in a detached repository", () => { + expect( + resolveDefaultWorktreeBaseBranch([ + { name: "origin/develop", current: false, isDefault: true, isRemote: true }, + ]), + ).toBe("origin/develop"); + }); + + it("does not invent a base for empty or detached local-only repositories", () => { + expect(resolveDefaultWorktreeBaseBranch([])).toBeNull(); + expect(resolveDefaultWorktreeBaseBranch([{ ...current, current: false }])).toBeNull(); + expect(resolveDefaultWorktreeBaseBranch([{ ...current, isRemote: true }])).toBeNull(); + }); +}); + describe("normalizeGitRemoteUrl", () => { it("canonicalizes equivalent GitHub remotes across protocol variants", () => { expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index d5866d32e..30a575fe8 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -12,6 +12,17 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; + +export function resolveDefaultWorktreeBaseBranch( + refs: ReadonlyArray>, +): string | null { + return ( + refs.find((ref) => ref.isDefault)?.name ?? + refs.find((ref) => ref.current && !ref.isRemote)?.name ?? + null + ); +} + // Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` // via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly // that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 32691d242..5e8dbb912 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -3,6 +3,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from " import { applyClaudePromptEffortPrefix, + applyConfiguredReasoningEffortDefault, buildExplicitProviderOptionSelectionsFromDescriptors, buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, @@ -10,6 +11,7 @@ import { formatCodexModelName, formatModelSlugName, getProviderOptionCurrentLabel, + getConfiguredReasoningEffort, getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, @@ -224,6 +226,177 @@ describe("descriptor helpers", () => { }); }); +describe("configured reasoning effort", () => { + const input = { + modelSelection: createModelSelection(ProviderInstanceId.make("codex-work"), "gpt-5.4"), + driverKind: ProviderDriverKind.make("codex"), + capabilities: codexCaps, + defaultModelSelection: createModelSelection( + ProviderInstanceId.make("codex-personal"), + "gpt-5.4", + [{ id: "reasoningEffort", value: "xhigh" }], + ), + defaultDriverKind: ProviderDriverKind.make("codex"), + }; + + it("inherits the current configured effort across Codex accounts", () => { + expect(getConfiguredReasoningEffort(input)).toBe("xhigh"); + expect( + getConfiguredReasoningEffort({ + ...input, + modelSelection: createModelSelection(ProviderInstanceId.make("codex-work"), " 5.4 "), + defaultModelSelection: { ...input.defaultModelSelection, model: "openai.gpt-5.4" }, + }), + ).toBe("xhigh"); + expect( + getConfiguredReasoningEffort({ + ...input, + modelSelection: { ...input.modelSelection, model: "openai.gpt-5.3" }, + defaultModelSelection: { ...input.defaultModelSelection, model: "gpt-5.3-codex" }, + }), + ).toBe("xhigh"); + }); + + it("preserves explicit effort while allowing unrelated options", () => { + for (const value of ["high", "unsupported", true]) { + const explicit = { + ...input, + modelSelection: { + ...input.modelSelection, + options: [{ id: "reasoningEffort", value }], + }, + }; + expect(getConfiguredReasoningEffort(explicit)).toBeUndefined(); + expect(applyConfiguredReasoningEffortDefault(explicit)).toBe(codexCaps); + } + expect( + getConfiguredReasoningEffort({ + ...input, + modelSelection: { ...input.modelSelection, options: [{ id: "fastMode", value: true }] }, + }), + ).toBe("xhigh"); + }); + + it("does not inherit for other models or provider drivers", () => { + expect( + getConfiguredReasoningEffort({ + ...input, + modelSelection: { ...input.modelSelection, model: "gpt-5.3-codex" }, + }), + ).toBeUndefined(); + expect( + getConfiguredReasoningEffort({ ...input, driverKind: ProviderDriverKind.make("claude") }), + ).toBeUndefined(); + expect( + getConfiguredReasoningEffort({ + ...input, + defaultDriverKind: ProviderDriverKind.make("claude"), + }), + ).toBeUndefined(); + expect( + getConfiguredReasoningEffort({ ...input, defaultDriverKind: undefined }), + ).toBeUndefined(); + }); + + it("leaves catalog fallback when the configured effort or capabilities are unavailable", () => { + for (const value of ["unsupported", true]) { + expect( + getConfiguredReasoningEffort({ + ...input, + defaultModelSelection: { + ...input.defaultModelSelection, + options: [{ id: "reasoningEffort", value }], + }, + }), + ).toBeUndefined(); + } + expect( + getConfiguredReasoningEffort({ ...input, defaultModelSelection: undefined }), + ).toBeUndefined(); + expect( + getConfiguredReasoningEffort({ + ...input, + defaultModelSelection: createModelSelection( + input.defaultModelSelection.instanceId, + input.defaultModelSelection.model, + ), + }), + ).toBeUndefined(); + expect(getConfiguredReasoningEffort({ ...input, capabilities: undefined })).toBeUndefined(); + expect( + getConfiguredReasoningEffort({ ...input, capabilities: { optionDescriptors: [] } }), + ).toBeUndefined(); + expect( + getConfiguredReasoningEffort({ + ...input, + capabilities: { + optionDescriptors: [{ id: "reasoningEffort", label: "Reasoning", type: "boolean" }], + }, + }), + ).toBeUndefined(); + expect( + getConfiguredReasoningEffort({ + ...input, + capabilities: { + optionDescriptors: [ + { id: "reasoningEffort", label: "Reasoning", type: "select", options: [] }, + ], + }, + }), + ).toBeUndefined(); + expect( + applyConfiguredReasoningEffortDefault({ ...input, defaultModelSelection: undefined }), + ).toBe(codexCaps); + expect( + applyConfiguredReasoningEffortDefault({ ...input, capabilities: undefined }), + ).toBeUndefined(); + }); + + it("updates only display defaults without mutating capabilities or persisting inherited options", () => { + const inherited = applyConfiguredReasoningEffortDefault(input); + expect(inherited?.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "xhigh", label: "Extra High", isDefault: true }, + { id: "high", label: "High", isDefault: false }, + ], + currentValue: "xhigh", + }, + codexCaps.optionDescriptors?.[1], + ]); + expect(inherited?.optionDescriptors?.[1]).toBe(codexCaps.optionDescriptors?.[1]); + expect(codexCaps.optionDescriptors?.[0]?.currentValue).toBe("high"); + expect(input.modelSelection.options).toBeUndefined(); + expect( + buildExplicitProviderOptionSelectionsFromDescriptors(inherited?.optionDescriptors, undefined), + ).toBeUndefined(); + }); + + it("reads only the current configured effort when defaults change", () => { + const changed = { + ...input, + defaultModelSelection: { + ...input.defaultModelSelection, + options: [{ id: "reasoningEffort", value: "high" }], + }, + }; + expect(getConfiguredReasoningEffort(changed)).toBe("high"); + expect( + applyConfiguredReasoningEffortDefault(changed)?.optionDescriptors?.[0]?.currentValue, + ).toBe("high"); + expect(getConfiguredReasoningEffort(input)).toBe("xhigh"); + expect( + getConfiguredReasoningEffort({ + ...input, + defaultModelSelection: { ...input.defaultModelSelection, model: "gpt-5.3-codex" }, + }), + ).toBeUndefined(); + }); +}); + describe("applyClaudePromptEffortPrefix", () => { it("keeps slash commands intact when ultrathink is selected", () => { expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 0e05fb692..5618b1a58 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -344,6 +344,68 @@ export function normalizeModelSlug( return typeof aliased === "string" ? aliased : trimmed; } +interface ConfiguredReasoningEffortInput { + modelSelection: ModelSelection; + driverKind: ProviderDriverKind; + capabilities: ModelCapabilities | undefined; + defaultModelSelection: ModelSelection | undefined; + defaultDriverKind: ProviderDriverKind | undefined; +} + +export function getConfiguredReasoningEffort( + input: ConfiguredReasoningEffortInput, +): string | undefined { + const { modelSelection, driverKind, capabilities, defaultModelSelection, defaultDriverKind } = + input; + if ( + driverKind !== "codex" || + defaultDriverKind !== "codex" || + !defaultModelSelection || + modelSelection.options?.some((selection) => selection.id === "reasoningEffort") + ) { + return undefined; + } + const model = normalizeModelSlug(codexModelFamily(modelSelection.model.trim()), driverKind); + const defaultModel = normalizeModelSlug( + codexModelFamily(defaultModelSelection.model.trim()), + defaultDriverKind, + ); + if (!model || model !== defaultModel) { + return undefined; + } + const effort = getModelSelectionStringOptionValue(defaultModelSelection, "reasoningEffort"); + const descriptor = capabilities?.optionDescriptors?.find( + (candidate) => candidate.id === "reasoningEffort" && candidate.type === "select", + ); + return descriptor?.type === "select" && descriptor.options.some((option) => option.id === effort) + ? effort + : undefined; +} + +export function applyConfiguredReasoningEffortDefault( + input: ConfiguredReasoningEffortInput, +): ModelCapabilities | undefined { + const effort = getConfiguredReasoningEffort(input); + if (effort === undefined || !input.capabilities) { + return input.capabilities; + } + return { + ...input.capabilities, + optionDescriptors: input.capabilities.optionDescriptors?.map((descriptor) => + descriptor.id === "reasoningEffort" && descriptor.type === "select" + ? { + ...descriptor, + currentValue: effort, + options: descriptor.options.map((option) => ({ + ...option, + isDefault: option.id === effort, + })), + } + : descriptor, + ), + }; +} + /** Custom model identifiers are provider-owned, so only trim them; never expand aliases. */ export function normalizeCustomModelSlug(model: string | null | undefined): string | null { if (typeof model !== "string") { diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index 43b2d9c2d..8bc452029 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -1,8 +1,37 @@ import { describe, expect, it } from "vite-plus/test"; -import { T3_MCP_TOOL_NAMES, resolveT3McpToolPresentation } from "./t3McpToolPresentation.ts"; +import { + T3_MCP_TOOL_NAMES, + resolveT3McpToolDefinition, + resolveT3McpToolPresentation, + resolveT3McpToolSummaryAction, +} from "./t3McpToolPresentation.ts"; describe("resolveT3McpToolPresentation", () => { + it("presents deferred self-settlement as a request across provider name forms", () => { + expect(T3_MCP_TOOL_NAMES.has("t3_thread_settle")).toBe(true); + for (const prefix of ["", "mcp__t3-code__", "T3-code."]) { + const name = `${prefix}t3_thread_settle`; + expect(resolveT3McpToolDefinition(name)).toEqual({ + displayName: "Request settlement of this thread", + labels: [ + "Request settlement of", + "Requesting settlement of", + "Requested settlement of", + "this thread", + ], + icon: "t3-code", + summaryAction: "thread-settle", + }); + expect(resolveT3McpToolPresentation(name)).toEqual({ + displayName: "Request settlement of this thread", + logo: "t3-code", + }); + expect(resolveT3McpToolSummaryAction(name)).toBe("thread-settle"); + } + expect(resolveT3McpToolDefinition("mcp__another-server__t3_thread_settle")).toBeNull(); + }); + it("recognizes every T3 tool across provider prefixes and completion suffixes", () => { for (const tool of T3_MCP_TOOL_NAMES) { const presentation = resolveT3McpToolPresentation(tool); diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index d85104cf4..be71bb213 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -21,6 +21,7 @@ export type T3McpToolSummaryAction = | "thread-send" | "thread-wait" | "thread-interrupt" + | "thread-settle" | "thread-configuration" | "thread-configure" | "thread-fork" @@ -138,6 +139,10 @@ const T3_MCP_TOOLS: Readonly> = { ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"], "thread-interrupt", ), + t3_thread_settle: tool( + ["Request settlement of", "Requesting settlement of", "Requested settlement of", "this thread"], + "thread-settle", + ), t3_worktree_handoff: tool( ["Hand off", "Handing off", "Handed off", "thread to a git worktree"], "worktree-handoff", diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 8208e5b7e..356451c99 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -785,4 +785,207 @@ describe("mergeUsage", () => { expect(merged.daily).toHaveLength(1); expect(merged.daily[0]?.costUsd).toBe(10); }); + + it("reconciles provider and model detail vectors after copied and overlapping sources", () => { + const claudeSource = { + provider: "claude" as const, + hostId: "mac", + homePath: "/claude", + distinctSessions: 2, + }; + const accepted = [ + bucket({ + sourcePath: "/claude", + model: "primary", + costUsd: 6, + costSource: "providerReported", + records: 2, + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 50, + cacheCreationTokens: 10, + outputTokens: 30, + reasoningTokens: 5, + }, + }), + bucket({ + sourcePath: "/claude", + model: "primary", + day: "2026-08-06" as UsageDay, + costUsd: 2, + records: 5, + unpricedRecords: 2, + totals: { + uncachedInputTokens: 200, + cachedInputTokens: 100, + cacheCreationTokens: 20, + outputTokens: 40, + reasoningTokens: 10, + }, + }), + ]; + const complete = summary(accepted, [claudeSource]); + const partial = summary( + [ + ...accepted.map((entry) => ({ + ...entry, + costUsd: 900, + totals: { ...entry.totals, outputTokens: 9000 }, + })), + bucket({ + sourcePath: "/claude", + day: "2026-08-08" as UsageDay, + model: "secondary", + costUsd: 0, + costSource: "unpriced", + records: 3, + unpricedRecords: 3, + totals: { + uncachedInputTokens: 30, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 10, + reasoningTokens: 4, + }, + }), + ], + [claudeSource], + ); + const merged = mergeUsage( + [ + environment("env-a", complete), + environment("env-copy", complete), + environment("env-partial", { + ...partial, + readAt: "2026-08-09T00:00:00.000Z", + sources: partial.sources.map((source) => ({ + ...source, + status: "partial", + distinctSessions: 3, + })), + }), + environment( + "env-codex", + summary( + [ + bucket({ + provider: "codex", + model: "codex-model", + costUsd: 4, + costSource: "providerReported", + records: 1, + totals: { + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 0, + outputTokens: 5, + reasoningTokens: 2, + }, + }), + ], + [{ provider: "codex", hostId: "linux", homePath: "/codex" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.providers.find((entry) => entry.provider === "claude")).toMatchObject({ + costUsd: 8, + totalTokens: 590, + records: 10, + sessions: 3, + totals: { + uncachedInputTokens: 330, + cachedInputTokens: 150, + cacheCreationTokens: 30, + outputTokens: 80, + reasoningTokens: 19, + }, + providerReportedRecords: 2, + modelPricedRecords: 3, + unpricedRecords: 5, + }); + expect(merged.models.find((entry) => entry.model === "primary")).toMatchObject({ + totalTokens: 550, + totals: { + uncachedInputTokens: 300, + cachedInputTokens: 150, + cacheCreationTokens: 30, + outputTokens: 70, + reasoningTokens: 15, + }, + providerReportedRecords: 2, + modelPricedRecords: 3, + unpricedRecords: 2, + }); + expect(merged.models.find((entry) => entry.model === "secondary")).toMatchObject({ + totalTokens: 40, + providerReportedRecords: 0, + modelPricedRecords: 0, + unpricedRecords: 3, + }); + expect(merged.totalTokens).toBe(625); + expect(merged.records).toBe(11); + expect(merged.sessions).toBe(4); + expect(merged.duplicateSources).toHaveLength(2); + for (const field of [ + "uncachedInputTokens", + "cachedInputTokens", + "cacheCreationTokens", + "outputTokens", + "reasoningTokens", + ] as const) { + expect(merged.providers.reduce((sum, entry) => sum + entry.totals[field], 0)).toBe( + merged[field], + ); + expect(merged.models.reduce((sum, entry) => sum + entry.totals[field], 0)).toBe( + merged[field], + ); + } + expect(merged.costQuality.providerReportedShare).toBe(3 / 11); + expect(merged.costQuality.modelPricedShare).toBe(3 / 11); + expect(merged.costQuality.unpricedShare).toBe(5 / 11); + }); + + it("retains zero-priced coverage and does not count reasoning twice", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ + records: 1, + costUsd: 0, + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 20, + reasoningTokens: 20, + }, + }), + ], + [{ provider: "claude", hostId: "mac", homePath: "/claude" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.providers[0]).toMatchObject({ + totalTokens: 20, + records: 1, + providerReportedRecords: 0, + modelPricedRecords: 1, + unpricedRecords: 0, + }); + expect(merged.models[0]).toMatchObject({ + totalTokens: 20, + modelPricedRecords: 1, + unpricedRecords: 0, + }); + expect(isModelCostUnknown(merged.models[0]!)).toBe(false); + }); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 07718c099..7d6a9f7c3 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -28,6 +28,10 @@ export interface ProviderTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; + readonly totals: UsageTokenTotals; + readonly providerReportedRecords: number; + readonly modelPricedRecords: number; + readonly unpricedRecords: number; readonly sessions: number; readonly costShare: number; readonly tokenShare: number; @@ -40,6 +44,9 @@ export interface ModelTotals { readonly totalTokens: number; readonly tokens: UsageTokenTotals; readonly records: number; + readonly totals: UsageTokenTotals; + readonly providerReportedRecords: number; + readonly modelPricedRecords: number; /** * Records whose tokens are counted here but which contributed nothing to * `costUsd`. When it equals `records` the cost is unknown, not zero. @@ -314,6 +321,35 @@ function bucketTokens(bucket: UsageBucket): number { ); } +function emptyDetailTotals() { + return { + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + } satisfies UsageTokenTotals, + providerReportedRecords: 0, + modelPricedRecords: 0, + unpricedRecords: 0, + }; +} + +function addDetailTotals(detail: ReturnType, bucket: UsageBucket) { + detail.totals.uncachedInputTokens += bucket.totals.uncachedInputTokens; + detail.totals.cachedInputTokens += bucket.totals.cachedInputTokens; + detail.totals.cacheCreationTokens += bucket.totals.cacheCreationTokens; + detail.totals.outputTokens += bucket.totals.outputTokens; + detail.totals.reasoningTokens += bucket.totals.reasoningTokens; + const providerReportedRecords = bucket.costSource === "providerReported" ? bucket.records : 0; + detail.providerReportedRecords += providerReportedRecords; + detail.unpricedRecords += bucket.unpricedRecords; + // Match the existing cost-quality classification; the wire bucket carries + // unpriced counts and one cost source, not per-response pricing provenance. + detail.modelPricedRecords += bucket.records - providerReportedRecords - bucket.unpricedRecords; +} + export function isCompatibleUsageContractVersion(version: number, expected: number): boolean { return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected; } @@ -402,17 +438,21 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number; sessions: number } + ReturnType & { + costUsd: number; + totalTokens: number; + records: number; + sessions: number; + } >(); const modelAccumulator = new Map< string, - { + ReturnType & { provider: UsageProviderKind; costUsd: number; totalTokens: number; tokens: UsageTokenTotals; records: number; - unpricedRecords: number; unpricedTokens: number; } >(); @@ -449,6 +489,7 @@ export function mergeUsage( sessions += providerSessions; if (providerSessions === 0) continue; const provider = providerAccumulator.get(providerKind) ?? { + ...emptyDetailTotals(), costUsd: 0, totalTokens: 0, records: 0, @@ -482,6 +523,7 @@ export function mergeUsage( speedCost.premium += bucket.speedPremiumUsd ?? 0; const provider = providerAccumulator.get(bucket.provider) ?? { + ...emptyDetailTotals(), costUsd: 0, totalTokens: 0, records: 0, @@ -490,10 +532,12 @@ export function mergeUsage( provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; provider.records += bucket.records; + addDetailTotals(provider, bucket); providerAccumulator.set(bucket.provider, provider); const modelKey = `${bucket.provider} ${bucket.model}`; const model = modelAccumulator.get(modelKey) ?? { + ...emptyDetailTotals(), provider: bucket.provider, costUsd: 0, totalTokens: 0, @@ -505,7 +549,6 @@ export function mergeUsage( reasoningTokens: 0, }, records: 0, - unpricedRecords: 0, unpricedTokens: 0, }; model.costUsd += bucket.costUsd; @@ -518,7 +561,7 @@ export function mergeUsage( reasoningTokens: model.tokens.reasoningTokens + bucket.totals.reasoningTokens, }; model.records += bucket.records; - model.unpricedRecords += bucket.unpricedRecords; + addDetailTotals(model, bucket); if (bucket.records > 0) { model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records; } @@ -567,6 +610,10 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, + totals: totals.totals, + providerReportedRecords: totals.providerReportedRecords, + modelPricedRecords: totals.modelPricedRecords, + unpricedRecords: totals.unpricedRecords, sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, @@ -581,6 +628,9 @@ export function mergeUsage( totalTokens: totals.totalTokens, tokens: totals.tokens, records: totals.records, + totals: totals.totals, + providerReportedRecords: totals.providerReportedRecords, + modelPricedRecords: totals.modelPricedRecords, unpricedRecords: totals.unpricedRecords, unpricedTokens: totals.unpricedTokens, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02883a8b6..7a0108ae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,6 +731,9 @@ importers: morphicons: specifier: ^1.7.1 version: 1.7.1(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + pdfjs-dist: + specifier: 6.3.289 + version: 6.3.289 react: specifier: 19.2.6 version: 19.2.6 @@ -3453,6 +3456,81 @@ packages: resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} + '@napi-rs/canvas-android-arm64@1.0.10': + resolution: {integrity: sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.10': + resolution: {integrity: sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.10': + resolution: {integrity: sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.10': + resolution: {integrity: sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.10': + resolution: {integrity: sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@1.0.10': + resolution: {integrity: sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.10': + resolution: {integrity: sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@1.0.10': + resolution: {integrity: sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@1.0.10': + resolution: {integrity: sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.10': + resolution: {integrity: sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.10': + resolution: {integrity: sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.10': + resolution: {integrity: sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==} + engines: {node: '>= 10'} + '@napi-rs/keyring-darwin-arm64@1.3.0': resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} engines: {node: '>= 10'} @@ -9610,6 +9688,10 @@ packages: pause-stream@0.0.11: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + pdfjs-dist@6.3.289: + resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==} + engines: {node: '>=22.13.0 || >=24'} + pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -14156,6 +14238,54 @@ snapshots: strict-event-emitter: 0.5.1 optional: true + '@napi-rs/canvas-android-arm64@1.0.10': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.10': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.10': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.10': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.10': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.10': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.10': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.10': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.10': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.10': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.10': + optional: true + + '@napi-rs/canvas@1.0.10': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.10 + '@napi-rs/canvas-darwin-arm64': 1.0.10 + '@napi-rs/canvas-darwin-x64': 1.0.10 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.10 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.10 + '@napi-rs/canvas-linux-arm64-musl': 1.0.10 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.10 + '@napi-rs/canvas-linux-x64-gnu': 1.0.10 + '@napi-rs/canvas-linux-x64-musl': 1.0.10 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.10 + '@napi-rs/canvas-win32-x64-msvc': 1.0.10 + optional: true + '@napi-rs/keyring-darwin-arm64@1.3.0': optional: true @@ -20525,6 +20655,10 @@ snapshots: dependencies: through: 2.3.8 + pdfjs-dist@6.3.289: + optionalDependencies: + '@napi-rs/canvas': 1.0.10 + pe-library@0.4.1: {} pend@1.2.0: {} diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index cc8c155bd..b2473a05b 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -60,6 +61,7 @@ import { resolveWindowsServerAsarIgnoreGlobs, resourceMonitorExecutableName, resolveGitHubPublishConfig, + resolveGitCommitHash, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, resolvePackageManagerUserAgent, @@ -265,20 +267,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest"); }); - it("switches desktop packaging product names to nightly for nightly builds", () => { - assert.equal(resolveDesktopProductName("0.0.17"), "T3 Code (Alpha)"); - assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "T3 Code (Nightly)"); + it("keeps the Jones Code app name across release channels", () => { + assert.equal(resolveDesktopProductName("0.0.17"), "Jones Code"); + assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "Jones Code"); + assert.equal(resolveDesktopProductName("0.0.44-preview.20261002.36963972634"), "Jones Code"); }); - it("switches desktop packaging icons to the nightly artwork for nightly versions", () => { + it("keeps the JC Mac icon while selecting channel artwork for other platforms", () => { assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17"), { - macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, + macIconPng: BRAND_ASSET_PATHS.jonesMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, }); assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17-nightly.20260413.42"), { - macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, + macIconPng: BRAND_ASSET_PATHS.jonesMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, }); @@ -690,7 +693,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "**/*.map", ]); assert.deepStrictEqual(mac.dmg, { - title: "T3 Code (Alpha) 1.2.3 Installer", + title: "Jones Code 1.2.3 Installer", background: "dmg/dmg-background-latest.png", window: { width: 640, height: 432 }, contents: [ @@ -2483,3 +2486,57 @@ it("ignores trailing separators", () => { ancestorNodeModulesPaths("C:\\tmp\\probe\\app", "\\"), ); }); + +it.effect.each([ + { + stdout: " ABCDEF1234567890ABCDEF1234567890ABCDEF12\n", + exitCode: 0, + expected: "abcdef1234567890abcdef1234567890abcdef12", + }, + { stdout: "abcdef123456", exitCode: 0, expected: "unknown" }, + { stdout: "g".repeat(40), exitCode: 0, expected: "unknown" }, + { stdout: "a".repeat(41), exitCode: 0, expected: "unknown" }, + { stdout: "", exitCode: 0, expected: "unknown" }, + { stdout: "a".repeat(40), exitCode: 1, expected: "unknown" }, +])("resolves only a successful full Git HEAD: %j", ({ stdout, exitCode, expected }) => + Effect.gen(function* () { + const commands: ChildProcess.Command[] = []; + const hash = yield* resolveGitCommitHash("/synthetic/repository").pipe( + Effect.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + commands.push(command); + return Effect.succeed(mockProcess(exitCode, stdout)); + }), + ), + ), + ); + assert.equal(hash, expected); + assert.deepEqual(commands, [ + ChildProcess.make("git", ["rev-parse", "HEAD"], { cwd: "/synthetic/repository" }), + ]); + }), +); + +it.effect("reports unknown when resolving Git HEAD cannot spawn", () => + resolveGitCommitHash("/synthetic/repository").pipe( + Effect.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "git", + description: "synthetic unavailable Git", + }), + ), + ), + ), + ), + Effect.tap((hash) => Effect.sync(() => assert.equal(hash, "unknown"))), + ), +); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index d033ef099..4102b8a75 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -823,9 +823,9 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( return { stdout, stderr, exitCode } as const; }); -const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { +export const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { const result = yield* spawnAndCollectOutput( - ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { + ChildProcess.make("git", ["rev-parse", "HEAD"], { cwd: repoRoot, }), ).pipe( @@ -840,7 +840,7 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo return "unknown"; } const hash = result.stdout.trim(); - if (!/^[0-9a-f]{7,40}$/i.test(hash)) { + if (!/^[0-9a-f]{40}$/i.test(hash)) { return "unknown"; } return hash.toLowerCase(); @@ -2612,14 +2612,14 @@ export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets { if (resolveDesktopUpdateChannel(version) === "nightly") { return { - macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, + macIconPng: BRAND_ASSET_PATHS.jonesMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, }; } return { - macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, + macIconPng: BRAND_ASSET_PATHS.jonesMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, }; @@ -2642,10 +2642,8 @@ export function resolvePackageManagerUserAgent(packageManager: string): string { return `${trimmed.slice(0, versionSeparator)}/${trimmed.slice(versionSeparator + 1)}`; } -export function resolveDesktopProductName(version: string): string { - return resolveDesktopUpdateChannel(version) === "nightly" - ? "T3 Code (Nightly)" - : (desktopPackageJson.productName ?? "T3 Code"); +export function resolveDesktopProductName(_version: string): string { + return desktopPackageJson.productName ?? "Jones Code"; } export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( diff --git a/scripts/export-jones-code-icon.swift b/scripts/export-jones-code-icon.swift new file mode 100644 index 000000000..214dc5be3 --- /dev/null +++ b/scripts/export-jones-code-icon.swift @@ -0,0 +1,90 @@ +import AppKit + +enum Segment { + case move(Double, Double) + case line(Double, Double) + case curve(Double, Double, Double, Double, Double, Double) + + var svg: String { + switch self { + case let .move(x, y): return "M\(x) \(y)" + case let .line(x, y): return "L\(x) \(y)" + case let .curve(a, b, c, d, x, y): return "C\(a) \(b) \(c) \(d) \(x) \(y)" + } + } + + func append(to path: CGMutablePath) { + switch self { + case let .move(x, y): path.move(to: CGPoint(x: x, y: y)) + case let .line(x, y): path.addLine(to: CGPoint(x: x, y: y)) + case let .curve(a, b, c, d, x, y): + path.addCurve(to: CGPoint(x: x, y: y), control1: CGPoint(x: a, y: b), control2: CGPoint(x: c, y: d)) + } + } +} + +let letters: [[Segment]] = [ + [.move(246, 616), .curve(246, 710, 424, 710, 424, 604), .line(424, 346), + .move(318, 346), .line(424, 346)], + [.move(756, 398), .curve(670, 312, 546, 350, 546, 512), + .curve(546, 674, 670, 712, 756, 626)], +] +let paths = letters.map { $0.map(\.svg).joined(separator: " ") } +let svg = """ + + + + + + + + + + + + + + +""" + +let repo = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() +let output = repo.appendingPathComponent("assets/jones-code", isDirectory: true) +try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) +try svg.write(to: output.appendingPathComponent("jc-mark.svg"), atomically: true, encoding: .utf8) + +guard let image = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0), + let graphics = NSGraphicsContext(bitmapImageRep: image) else { + fatalError("Could not create the icon bitmap") +} +let context = graphics.cgContext +context.translateBy(x: 0, y: 1024) +context.scaleBy(x: 1, y: -1) +context.saveGState() +context.addPath(CGPath(roundedRect: CGRect(x: 72, y: 72, width: 880, height: 880), + cornerWidth: 200, cornerHeight: 200, transform: nil)) +context.clip() +let colors = [CGColor(red: 16/255, green: 43/255, blue: 75/255, alpha: 1), + CGColor(red: 37/255, green: 32/255, blue: 79/255, alpha: 1)] +guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors as CFArray, + locations: [0, 1]) else { + fatalError("Could not create the icon gradient") +} +context.drawLinearGradient(gradient, start: CGPoint(x: 72, y: 72), end: CGPoint(x: 952, y: 952), options: []) +context.restoreGState() +context.setStrokeColor(CGColor(gray: 1, alpha: 1)) +context.setLineWidth(76) +context.setLineCap(.round) +context.setLineJoin(.round) +for segments in letters { + let path = CGMutablePath() + for segment in segments { segment.append(to: path) } + context.addPath(path) + context.strokePath() +} +guard let png = image.representation(using: .png, properties: [:]) else { + fatalError("Could not encode the icon PNG") +} +try png.write(to: output.appendingPathComponent("jc-macos-1024.png"), options: .atomic) +print("Exported Jones Code SVG and 1024px Mac icon to \(output.path)") diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 5bbcb0ef2..83b8e9c39 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -1,4 +1,6 @@ export const BRAND_ASSET_PATHS = { + jonesMacIconPng: "assets/jones-code/jc-macos-1024.png", + developmentIconComposerProject: "assets/dev/app-icon.icon", developmentIosIconPng: "assets/dev/blueprint-ios-1024.png", developmentUniversalIconPng: "assets/dev/blueprint-universal-1024.png",