| author | |
| committer | |
| log | 1bbca0e78202c8ece73351fccd29f3c99070b82e |
| tree | b0edaba1744b2f4cd02469373c07e912e9b61496 |
| parent | 3b75e607eb909522a8f5562fb40e44efc72bb66c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>7 files changed, 252 insertions(+), 81 deletions(-)
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+64-8| ... | ... | @@ -465,11 +465,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 465 | 465 | }); |
| 466 | 466 | |
| 467 | 467 | it.each([ |
| 468 | { delivery: "buffered", enableLegacyTokenStreaming: false }, | |
| 469 | { delivery: "streamed", enableLegacyTokenStreaming: true }, | |
| 468 | { delivery: "buffered", responseStreamingMode: "paragraph" as const }, | |
| 469 | { delivery: "streamed", responseStreamingMode: "token" as const }, | |
| 470 | 470 | ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => { |
| 471 | 471 | const harness = await createHarness({ |
| 472 | serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming }, | |
| 472 | serverSettings: { responseStreamingMode: settings.responseStreamingMode }, | |
| 473 | 473 | }); |
| 474 | 474 | const threadId = asThreadId("thread-1"); |
| 475 | 475 | const turnId = asTurnId("opencode-aborted-turn"); |
| ... | ... | @@ -521,7 +521,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 521 | 521 | "finalizes old buffered text on late %s without stopping the newer turn", |
| 522 | 522 | async (terminalType) => { |
| 523 | 523 | const harness = await createHarness({ |
| 524 | serverSettings: { enableLegacyTokenStreaming: false }, | |
| 524 | serverSettings: { responseStreamingMode: "paragraph" }, | |
| 525 | 525 | }); |
| 526 | 526 | const threadId = asThreadId("thread-1"); |
| 527 | 527 | const oldTurnId = asTurnId("old-buffered-turn"); |
| ... | ... | @@ -605,7 +605,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 605 | 605 | { source: "an unspecified turn", turnId: undefined }, |
| 606 | 606 | ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => { |
| 607 | 607 | const harness = await createHarness({ |
| 608 | serverSettings: { enableLegacyTokenStreaming: true }, | |
| 608 | serverSettings: { responseStreamingMode: "token" }, | |
| 609 | 609 | }); |
| 610 | 610 | const threadId = asThreadId("thread-1"); |
| 611 | 611 | const stoppedTurnId = asTurnId("opencode-stopped-turn"); |
| ... | ... | @@ -2712,7 +2712,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2712 | 2712 | }); |
| 2713 | 2713 | |
| 2714 | 2714 | it("keeps streaming while an async question is pending", async () => { |
| 2715 | const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); | |
| 2715 | const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); | |
| 2716 | 2716 | const base = { |
| 2717 | 2717 | provider: ProviderDriverKind.make("codex"), |
| 2718 | 2718 | createdAt: "2026-01-01T00:00:00.000Z", |
| ... | ... | @@ -2954,7 +2954,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2954 | 2954 | }); |
| 2955 | 2955 | |
| 2956 | 2956 | it("starts a new streaming assistant message segment after approval", async () => { |
| 2957 | const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); | |
| 2957 | const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); | |
| 2958 | 2958 | const startedAt = "2026-03-28T07:00:00.000Z"; |
| 2959 | 2959 | const pausedAt = "2026-03-28T07:00:01.000Z"; |
| 2960 | 2960 | const resumedAt = "2026-03-28T07:00:02.000Z"; |
| ... | ... | @@ -3061,7 +3061,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 3061 | 3061 | }); |
| 3062 | 3062 | |
| 3063 | 3063 | it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { |
| 3064 | const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); | |
| 3064 | const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); | |
| 3065 | 3065 | const now = "2026-01-01T00:00:00.000Z"; |
| 3066 | 3066 | |
| 3067 | 3067 | await Effect.runPromise( |
| ... | ... | @@ -3235,6 +3235,62 @@ describe("ProviderRuntimeIngestion", () => { |
| 3235 | 3235 | ); |
| 3236 | 3236 | }); |
| 3237 | 3237 | |
| 3238 | it("holds every paragraph until completion in turn mode", async () => { | |
| 3239 | const harness = await createHarness({ serverSettings: { responseStreamingMode: "turn" } }); | |
| 3240 | const now = "2026-01-01T00:00:00.000Z"; | |
| 3241 | const codex = ProviderDriverKind.make("codex"); | |
| 3242 | const threadId = asThreadId("thread-1"); | |
| 3243 | const turnId = asTurnId("turn-wait-mode"); | |
| 3244 | const itemId = asItemId("item-wait-mode"); | |
| 3245 | ||
| 3246 | await harness.emitAndDrain([ | |
| 3247 | { | |
| 3248 | type: "turn.started", | |
| 3249 | eventId: asEventId("evt-wait-started"), | |
| 3250 | provider: codex, | |
| 3251 | createdAt: now, | |
| 3252 | threadId, | |
| 3253 | turnId, | |
| 3254 | }, | |
| 3255 | ]); | |
| 3256 | harness.advanceClock(1_000); | |
| 3257 | await harness.emitAndDrain([ | |
| 3258 | { | |
| 3259 | type: "content.delta", | |
| 3260 | eventId: asEventId("evt-wait-delta"), | |
| 3261 | provider: codex, | |
| 3262 | createdAt: now, | |
| 3263 | threadId, | |
| 3264 | turnId, | |
| 3265 | itemId, | |
| 3266 | payload: { | |
| 3267 | streamKind: "assistant_text", | |
| 3268 | delta: "First paragraph.\n\nSecond paragraph.\n\n", | |
| 3269 | }, | |
| 3270 | }, | |
| 3271 | ]); | |
| 3272 | const messageText = async () => | |
| 3273 | (await harness.readModel()).threads | |
| 3274 | .find((t) => t.id === threadId) | |
| 3275 | ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; | |
| 3276 | // Paragraph mode would have delivered both paragraphs by now. | |
| 3277 | expect(await messageText()).toBeUndefined(); | |
| 3278 | ||
| 3279 | await harness.emitAndDrain([ | |
| 3280 | { | |
| 3281 | type: "item.completed", | |
| 3282 | eventId: asEventId("evt-wait-completed"), | |
| 3283 | provider: codex, | |
| 3284 | createdAt: now, | |
| 3285 | threadId, | |
| 3286 | turnId, | |
| 3287 | itemId, | |
| 3288 | payload: { itemType: "assistant_message", status: "completed" }, | |
| 3289 | }, | |
| 3290 | ]); | |
| 3291 | expect(await messageText()).toBe("First paragraph.\n\nSecond paragraph.\n\n"); | |
| 3292 | }); | |
| 3293 | ||
| 3238 | 3294 | it("holds paragraphs that finish inside the pacing window and lands them together", async () => { |
| 3239 | 3295 | const harness = await createHarness(); |
| 3240 | 3296 | const codex = ProviderDriverKind.make("codex"); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+27-21| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { |
| 2 | 2 | ApprovalRequestId, |
| 3 | type AssistantDeliveryMode, | |
| 4 | 3 | CommandId, |
| 5 | 4 | MessageId, |
| 6 | 5 | type OrchestrationEvent, |
| ... | ... | @@ -14,7 +13,9 @@ import { |
| 14 | 13 | TurnId, |
| 15 | 14 | type OrchestrationCheckpointSummary, |
| 16 | 15 | type OrchestrationThreadActivity, |
| 16 | type ProjectId, | |
| 17 | 17 | type ProviderRuntimeEvent, |
| 18 | type ResponseStreamingMode, | |
| 18 | 19 | RuntimeRequestId, |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import * as Cache from "effect/Cache"; |
| ... | ... | @@ -1167,7 +1168,19 @@ const make = Effect.gen(function* () { |
| 1167 | 1168 | }); |
| 1168 | 1169 | }); |
| 1169 | 1170 | |
| 1170 | const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) => | |
| 1171 | const resolveResponseStreamingMode = (projectId: ProjectId) => | |
| 1172 | Effect.map( | |
| 1173 | serverSettingsService.getSettings, | |
| 1174 | (settings) => resolveProjectSettings(settings, projectId).settings.responseStreamingMode, | |
| 1175 | ); | |
| 1176 | ||
| 1177 | // `mode` is "turn" or "paragraph"; token mode never buffers. | |
| 1178 | const appendBufferedAssistantText = ( | |
| 1179 | messageId: MessageId, | |
| 1180 | delta: string, | |
| 1181 | mode: Exclude<ResponseStreamingMode, "token">, | |
| 1182 | atMillis: number, | |
| 1183 | ) => | |
| 1171 | 1184 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( |
| 1172 | 1185 | Effect.flatMap((existingText) => |
| 1173 | 1186 | Effect.gen(function* () { |
| ... | ... | @@ -1176,9 +1189,13 @@ const make = Effect.gen(function* () { |
| 1176 | 1189 | onSome: (text) => `${text}${delta}`, |
| 1177 | 1190 | }); |
| 1178 | 1191 | |
| 1179 | // Deliver finished paragraphs and closed code blocks early so the | |
| 1180 | // user sees progress without token-by-token repaints. | |
| 1181 | const { ready, rest } = splitBufferedAssistantText(nextText); | |
| 1192 | // Paragraph mode delivers finished paragraphs and closed code blocks | |
| 1193 | // early so the user sees progress without token-by-token repaints. | |
| 1194 | // Turn mode holds everything until the turn finishes or pauses. | |
| 1195 | const { ready, rest } = | |
| 1196 | mode === "paragraph" | |
| 1197 | ? splitBufferedAssistantText(nextText) | |
| 1198 | : { ready: "", rest: nextText }; | |
| 1182 | 1199 | const lastDeliveredAt = Option.getOrUndefined( |
| 1183 | 1200 | yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), |
| 1184 | 1201 | ); |
| ... | ... | @@ -1757,19 +1774,14 @@ const make = Effect.gen(function* () { |
| 1757 | 1774 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); |
| 1758 | 1775 | } |
| 1759 | 1776 | |
| 1760 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( | |
| 1761 | serverSettingsService.getSettings, | |
| 1762 | (settings) => | |
| 1763 | resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming | |
| 1764 | ? "streaming" | |
| 1765 | : "buffered", | |
| 1766 | ); | |
| 1767 | if (assistantDeliveryMode === "buffered") { | |
| 1777 | const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); | |
| 1778 | if (streamingMode !== "token") { | |
| 1768 | 1779 | // Pace on the server clock. OpenCode stamps every delta of a part |
| 1769 | 1780 | // with the part's start time, so the event time cannot measure gaps. |
| 1770 | 1781 | const spillChunk = yield* appendBufferedAssistantText( |
| 1771 | 1782 | assistantMessageId, |
| 1772 | 1783 | assistantDelta, |
| 1784 | streamingMode, | |
| 1773 | 1785 | yield* Clock.currentTimeMillis, |
| 1774 | 1786 | ); |
| 1775 | 1787 | if (spillChunk.length > 0) { |
| ... | ... | @@ -1807,15 +1819,9 @@ const make = Effect.gen(function* () { |
| 1807 | 1819 | turnId: pauseForUserTurnId, |
| 1808 | 1820 | streamingOnly: true, |
| 1809 | 1821 | }); |
| 1810 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( | |
| 1811 | serverSettingsService.getSettings, | |
| 1812 | (settings) => | |
| 1813 | resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming | |
| 1814 | ? "streaming" | |
| 1815 | : "buffered", | |
| 1816 | ); | |
| 1822 | const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); | |
| 1817 | 1823 | const flushedMessageIds = |
| 1818 | assistantDeliveryMode === "buffered" | |
| 1824 | streamingMode !== "token" | |
| 1819 | 1825 | ? yield* flushBufferedAssistantMessagesForTurn({ |
| 1820 | 1826 | event, |
| 1821 | 1827 | threadId: thread.id, |
apps/web/src/components/settings/SettingsPanels.tsx+136-35| ... | ... | @@ -38,6 +38,7 @@ import { |
| 38 | 38 | MIN_PANEL_ANIMATION_DURATION_MS, |
| 39 | 39 | MIN_PROMPT_FONT_SIZE, |
| 40 | 40 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 41 | type ResponseStreamingMode, | |
| 41 | 42 | MIN_TERMINAL_FONT_SIZE, |
| 42 | 43 | type QuitConfirmationMode, |
| 43 | 44 | } from "@t3tools/contracts/settings"; |
| ... | ... | @@ -94,6 +95,15 @@ import { isMacPlatform } from "../../lib/utils"; |
| 94 | 95 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; |
| 95 | 96 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 96 | 97 | import { formatRelativeTimeLabel } from "../../timestampFormat"; |
| 98 | import { | |
| 99 | AlertDialog, | |
| 100 | AlertDialogClose, | |
| 101 | AlertDialogDescription, | |
| 102 | AlertDialogFooter, | |
| 103 | AlertDialogHeader, | |
| 104 | AlertDialogPopup, | |
| 105 | AlertDialogTitle, | |
| 106 | } from "../ui/alert-dialog"; | |
| 97 | 107 | import { Button } from "../ui/button"; |
| 98 | 108 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 99 | 109 | import { |
| ... | ... | @@ -168,6 +178,18 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s |
| 168 | 178 | none: "None", |
| 169 | 179 | }; |
| 170 | 180 | |
| 181 | const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = { | |
| 182 | turn: "Wait for the full response", | |
| 183 | paragraph: "Show finished paragraphs", | |
| 184 | token: "Token by token (legacy)", | |
| 185 | }; | |
| 186 | ||
| 187 | const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string> = { | |
| 188 | turn: "Text appears once the agent finishes its turn.", | |
| 189 | paragraph: "Each paragraph or code block appears as soon as it is complete.", | |
| 190 | token: "Every token repaints the message as it arrives. Slower and harder to read.", | |
| 191 | }; | |
| 192 | ||
| 171 | 193 | const TIMESTAMP_FORMAT_LABELS = { |
| 172 | 194 | locale: "System default", |
| 173 | 195 | "12-hour": "12-hour", |
| ... | ... | @@ -564,9 +586,8 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 564 | 586 | ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled |
| 565 | 587 | ? ["Context window indicator"] |
| 566 | 588 | : []), |
| 567 | ...(settings.enableLegacyTokenStreaming !== | |
| 568 | DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming | |
| 569 | ? ["Stream token by token"] | |
| 589 | ...(settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode | |
| 590 | ? ["Response streaming"] | |
| 570 | 591 | : []), |
| 571 | 592 | ...(settings.enableProviderUpdateChecks !== |
| 572 | 593 | DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks |
| ... | ... | @@ -640,7 +661,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 640 | 661 | settings.fontSizeTerminal, |
| 641 | 662 | settings.glassOpacity, |
| 642 | 663 | settings.panelAnimationDurationMs, |
| 643 | settings.enableLegacyTokenStreaming, | |
| 664 | settings.responseStreamingMode, | |
| 644 | 665 | settings.enableProviderUpdateChecks, |
| 645 | 666 | settings.continueThreadsAfterServerUpdate, |
| 646 | 667 | settings.sidebarAutoSettleAfterDays, |
| ... | ... | @@ -744,7 +765,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 744 | 765 | sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, |
| 745 | 766 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, |
| 746 | 767 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, |
| 747 | enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, | |
| 768 | responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, | |
| 748 | 769 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, |
| 749 | 770 | continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, |
| 750 | 771 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| ... | ... | @@ -797,6 +818,44 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 797 | 818 | }; |
| 798 | 819 | } |
| 799 | 820 | |
| 821 | /** | |
| 822 | * Gate in front of the legacy token-by-token mode. The primary action steers | |
| 823 | * the user to paragraph streaming; the legacy path is the quiet option. | |
| 824 | */ | |
| 825 | function TokenStreamingWarningDialog({ | |
| 826 | open, | |
| 827 | onOpenChange, | |
| 828 | onConfirm, | |
| 829 | onUseParagraphs, | |
| 830 | }: { | |
| 831 | open: boolean; | |
| 832 | onOpenChange: (open: boolean) => void; | |
| 833 | onConfirm: () => void; | |
| 834 | onUseParagraphs: () => void; | |
| 835 | }) { | |
| 836 | return ( | |
| 837 | <AlertDialog open={open} onOpenChange={onOpenChange}> | |
| 838 | <AlertDialogPopup className="max-w-lg"> | |
| 839 | <AlertDialogHeader> | |
| 840 | <AlertDialogTitle>Token by token is a worse experience</AlertDialogTitle> | |
| 841 | <AlertDialogDescription> | |
| 842 | Token streaming repaints the message on every delta. It is slower, harder to read, and | |
| 843 | costs more CPU on every connected device. This mode stays only for backwards | |
| 844 | compatibility. Use paragraph streaming instead. | |
| 845 | </AlertDialogDescription> | |
| 846 | </AlertDialogHeader> | |
| 847 | <AlertDialogFooter> | |
| 848 | <Button variant="ghost-muted" className="sm:mr-auto" onClick={onConfirm}> | |
| 849 | Use token by token | |
| 850 | </Button> | |
| 851 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 852 | <Button onClick={onUseParagraphs}>Use paragraphs</Button> | |
| 853 | </AlertDialogFooter> | |
| 854 | </AlertDialogPopup> | |
| 855 | </AlertDialog> | |
| 856 | ); | |
| 857 | } | |
| 858 | ||
| 800 | 859 | function BackgroundActivityAdvancedDialog({ |
| 801 | 860 | open, |
| 802 | 861 | onOpenChange, |
| ... | ... | @@ -2014,7 +2073,6 @@ function AutoSettleDaysInput({ |
| 2014 | 2073 | const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([ |
| 2015 | 2074 | "legacy-plan-mode", |
| 2016 | 2075 | "legacy-context-window-indicator", |
| 2017 | "legacy-token-streaming", | |
| 2018 | 2076 | "legacy-sidebar", |
| 2019 | 2077 | ]); |
| 2020 | 2078 | |
| ... | ... | @@ -2082,35 +2140,6 @@ function LegacyFeaturesSection() { |
| 2082 | 2140 | /> |
| 2083 | 2141 | } |
| 2084 | 2142 | /> |
| 2085 | <SettingsRow | |
| 2086 | serverScoped | |
| 2087 | settingKeys={["enableLegacyTokenStreaming"]} | |
| 2088 | {...searchableSetting("legacy-token-streaming")} | |
| 2089 | description="Stream output token by token. This legacy mode is slower and harder to follow." | |
| 2090 | control={ | |
| 2091 | <ScopedSwitch | |
| 2092 | settingKeys={["enableLegacyTokenStreaming"]} | |
| 2093 | checked={settings.enableLegacyTokenStreaming} | |
| 2094 | onCheckedChange={(checked) => { | |
| 2095 | if (!checked) { | |
| 2096 | updateSettings({ enableLegacyTokenStreaming: false }); | |
| 2097 | return; | |
| 2098 | } | |
| 2099 | void (async () => { | |
| 2100 | const api = readLocalApi(); | |
| 2101 | const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( | |
| 2102 | [ | |
| 2103 | "Turn on token-by-token output?", | |
| 2104 | "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.", | |
| 2105 | ].join("\n"), | |
| 2106 | ); | |
| 2107 | if (confirmed) updateSettings({ enableLegacyTokenStreaming: true }); | |
| 2108 | })(); | |
| 2109 | }} | |
| 2110 | aria-label="Stream token by token (legacy)" | |
| 2111 | /> | |
| 2112 | } | |
| 2113 | /> | |
| 2114 | 2143 | <SettingsRow |
| 2115 | 2144 | {...searchableSetting("legacy-sidebar")} |
| 2116 | 2145 | description="Restore per-project thread trees instead of the default flat sidebar." |
| ... | ... | @@ -2144,6 +2173,8 @@ export function GeneralSettingsPanel() { |
| 2144 | 2173 | const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null; |
| 2145 | 2174 | const hasServerTargets = connectedEnvironments.length > 0; |
| 2146 | 2175 | const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); |
| 2176 | const [tokenStreamingWarningOpen, setTokenStreamingWarningOpen] = useState(false); | |
| 2177 | const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]); | |
| 2147 | 2178 | const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>( |
| 2148 | 2179 | readLastEnabledProjectGroupingMode(), |
| 2149 | 2180 | ); |
| ... | ... | @@ -2387,6 +2418,76 @@ export function GeneralSettingsPanel() { |
| 2387 | 2418 | </Select> |
| 2388 | 2419 | } |
| 2389 | 2420 | /> |
| 2421 | <SettingsRow | |
| 2422 | serverScoped | |
| 2423 | settingKeys={["responseStreamingMode"]} | |
| 2424 | {...searchableSetting("response-streaming")} | |
| 2425 | description={ | |
| 2426 | mixedResponseStreamingMode | |
| 2427 | ? "The selected targets use different streaming modes." | |
| 2428 | : RESPONSE_STREAMING_MODE_DESCRIPTIONS[settings.responseStreamingMode] | |
| 2429 | } | |
| 2430 | resetAction={ | |
| 2431 | settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode ? ( | |
| 2432 | <SettingResetButton | |
| 2433 | label="response streaming" | |
| 2434 | onClick={() => | |
| 2435 | updateSettings({ | |
| 2436 | responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, | |
| 2437 | }) | |
| 2438 | } | |
| 2439 | /> | |
| 2440 | ) : null | |
| 2441 | } | |
| 2442 | control={ | |
| 2443 | <> | |
| 2444 | <Select | |
| 2445 | value={mixedResponseStreamingMode ? null : settings.responseStreamingMode} | |
| 2446 | onValueChange={(value) => { | |
| 2447 | if (value === "token") { | |
| 2448 | // The legacy path needs an explicit confirmation. | |
| 2449 | setTokenStreamingWarningOpen(true); | |
| 2450 | return; | |
| 2451 | } | |
| 2452 | if (value === "turn" || value === "paragraph") { | |
| 2453 | updateSettings({ responseStreamingMode: value }); | |
| 2454 | } | |
| 2455 | }} | |
| 2456 | > | |
| 2457 | <SelectTrigger size="sm" className="w-full sm:w-56" aria-label="Response streaming"> | |
| 2458 | <SelectValue> | |
| 2459 | {(value: ResponseStreamingMode | null) => | |
| 2460 | value === null ? "Mixed" : RESPONSE_STREAMING_MODE_LABELS[value] | |
| 2461 | } | |
| 2462 | </SelectValue> | |
| 2463 | </SelectTrigger> | |
| 2464 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 2465 | <SelectItem hideIndicator value="turn"> | |
| 2466 | {RESPONSE_STREAMING_MODE_LABELS.turn} | |
| 2467 | </SelectItem> | |
| 2468 | <SelectItem hideIndicator value="paragraph"> | |
| 2469 | {RESPONSE_STREAMING_MODE_LABELS.paragraph} | |
| 2470 | </SelectItem> | |
| 2471 | <SelectItem hideIndicator value="token"> | |
| 2472 | {RESPONSE_STREAMING_MODE_LABELS.token} | |
| 2473 | </SelectItem> | |
| 2474 | </SelectPopup> | |
| 2475 | </Select> | |
| 2476 | <TokenStreamingWarningDialog | |
| 2477 | open={tokenStreamingWarningOpen} | |
| 2478 | onOpenChange={setTokenStreamingWarningOpen} | |
| 2479 | onConfirm={() => { | |
| 2480 | updateSettings({ responseStreamingMode: "token" }); | |
| 2481 | setTokenStreamingWarningOpen(false); | |
| 2482 | }} | |
| 2483 | onUseParagraphs={() => { | |
| 2484 | updateSettings({ responseStreamingMode: "paragraph" }); | |
| 2485 | setTokenStreamingWarningOpen(false); | |
| 2486 | }} | |
| 2487 | /> | |
| 2488 | </> | |
| 2489 | } | |
| 2490 | /> | |
| 2390 | 2491 | <SettingsRow |
| 2391 | 2492 | {...searchableSetting("hide-whitespace-changes")} |
| 2392 | 2493 | description="Set whether the diff panel ignores whitespace-only edits by default." |
apps/web/src/components/settings/settingsSearch.test.ts+1-1| ... | ... | @@ -339,7 +339,7 @@ describe("settings search targets", () => { |
| 339 | 339 | expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true); |
| 340 | 340 | expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true); |
| 341 | 341 | expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false); |
| 342 | const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!; | |
| 342 | const streaming = getSettingsSearchTargetScope("response-streaming")!; | |
| 343 | 343 | expect(streaming.scope).toBe("project-defaults"); |
| 344 | 344 | expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); |
| 345 | 345 | for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { |
apps/web/src/components/settings/settingsSearch.ts+7-7| ... | ... | @@ -261,6 +261,13 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 261 | 261 | to: "/settings/general", |
| 262 | 262 | searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"], |
| 263 | 263 | }, |
| 264 | { | |
| 265 | id: "response-streaming", | |
| 266 | title: "Response streaming", | |
| 267 | to: "/settings/general", | |
| 268 | scope: "project-defaults", | |
| 269 | searchTerms: ["output token paragraph buffered wait turn legacy"], | |
| 270 | }, | |
| 264 | 271 | { |
| 265 | 272 | id: "hide-whitespace-changes", |
| 266 | 273 | title: "Hide whitespace changes", |
| ... | ... | @@ -398,13 +405,6 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 398 | 405 | to: "/settings/general", |
| 399 | 406 | searchTerms: ["composer meter usage tokens circle old"], |
| 400 | 407 | }, |
| 401 | { | |
| 402 | id: "legacy-token-streaming", | |
| 403 | title: "Stream token by token (legacy)", | |
| 404 | to: "/settings/general", | |
| 405 | scope: "project-defaults", | |
| 406 | searchTerms: ["response output old compatibility"], | |
| 407 | }, | |
| 408 | 408 | { |
| 409 | 409 | id: "legacy-sidebar", |
| 410 | 410 | title: "Sidebar (legacy)", |
packages/contracts/src/orchestration.ts-2| ... | ... | @@ -143,8 +143,6 @@ export const ProviderRequestKind = Schema.Literals([ |
| 143 | 143 | "mcp-elicitation", |
| 144 | 144 | ]); |
| 145 | 145 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 146 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); | |
| 147 | export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; | |
| 148 | 146 | export const ProviderApprovalDecision = Schema.Literals([ |
| 149 | 147 | "accept", |
| 150 | 148 | "acceptForSession", |
packages/contracts/src/settings.ts+17-7| ... | ... | @@ -952,6 +952,15 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; |
| 952 | 952 | * background activity, theme. UI, search and the write planner derive |
| 953 | 953 | * eligibility from this list, so adding a key here is the whole opt-in. |
| 954 | 954 | */ |
| 955 | /** | |
| 956 | * How assistant text reaches clients while a turn runs. | |
| 957 | * - `turn`: hold the whole message until the turn finishes or pauses. | |
| 958 | * - `paragraph`: deliver each finished paragraph or closed code block. | |
| 959 | * - `token`: forward every provider delta. Legacy, kept for compatibility. | |
| 960 | */ | |
| 961 | export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]); | |
| 962 | export type ResponseStreamingMode = typeof ResponseStreamingMode.Type; | |
| 963 | ||
| 955 | 964 | export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ |
| 956 | 965 | "defaultModelSelection", |
| 957 | 966 | "defaultRuntimeMode", |
| ... | ... | @@ -968,7 +977,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ |
| 968 | 977 | "sidebarAutoSettleOnMerge", |
| 969 | 978 | "sidebarAutoSettleAfterDays", |
| 970 | 979 | "continueThreadsAfterServerUpdate", |
| 971 | "enableLegacyTokenStreaming", | |
| 980 | "responseStreamingMode", | |
| 972 | 981 | ] as const; |
| 973 | 982 | export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; |
| 974 | 983 | |
| ... | ... | @@ -993,16 +1002,17 @@ export const ProjectSettingsOverrides = Schema.Struct({ |
| 993 | 1002 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), |
| 994 | 1003 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 995 | 1004 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 996 | enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), | |
| 1005 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), | |
| 997 | 1006 | } satisfies Record<ProjectScopedServerSettingKey, unknown>); |
| 998 | 1007 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; |
| 999 | 1008 | |
| 1000 | 1009 | export const ServerSettings = Schema.Struct({ |
| 1001 | // Legacy token-by-token assistant output. Deliberately a fresh key (was | |
| 1010 | // How assistant text reaches clients during a turn. Deliberately a fresh | |
| 1011 | // key (was `enableLegacyTokenStreaming`, before that | |
| 1002 | 1012 | // `enableAssistantStreaming`): decoding drops the old key, so everyone, |
| 1003 | // including prior opt-ins, resets to the buffered default. | |
| 1004 | enableLegacyTokenStreaming: Schema.Boolean.pipe( | |
| 1005 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 1013 | // including prior token-streaming opt-ins, resets to the paragraph default. | |
| 1014 | responseStreamingMode: ResponseStreamingMode.pipe( | |
| 1015 | Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), | |
| 1006 | 1016 | ), |
| 1007 | 1017 | enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 1008 | 1018 | // Retain the update-era key; recovery now needs an environment-owned opt-in. |
| ... | ... | @@ -1337,7 +1347,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ |
| 1337 | 1347 | |
| 1338 | 1348 | export const ServerSettingsPatch = Schema.Struct({ |
| 1339 | 1349 | // Server settings |
| 1340 | enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), | |
| 1350 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), | |
| 1341 | 1351 | enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), |
| 1342 | 1352 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 1343 | 1353 | enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), |