| 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,11 +465,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 465 | }); | 465 | }); |
| 466 | 466 | ||
| 467 | it.each([ | 467 | it.each([ |
| 468 | { delivery: "buffered", enableLegacyTokenStreaming: false }, | 468 | { delivery: "buffered", responseStreamingMode: "paragraph" as const }, |
| 469 | { delivery: "streamed", enableLegacyTokenStreaming: true }, | 469 | { delivery: "streamed", responseStreamingMode: "token" as const }, |
| 470 | ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => { | 470 | ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => { |
| 471 | const harness = await createHarness({ | 471 | const harness = await createHarness({ |
| 472 | serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming }, | 472 | serverSettings: { responseStreamingMode: settings.responseStreamingMode }, |
| 473 | }); | 473 | }); |
| 474 | const threadId = asThreadId("thread-1"); | 474 | const threadId = asThreadId("thread-1"); |
| 475 | const turnId = asTurnId("opencode-aborted-turn"); | 475 | const turnId = asTurnId("opencode-aborted-turn"); |
| ... | @@ -521,7 +521,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -521,7 +521,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 521 | "finalizes old buffered text on late %s without stopping the newer turn", | 521 | "finalizes old buffered text on late %s without stopping the newer turn", |
| 522 | async (terminalType) => { | 522 | async (terminalType) => { |
| 523 | const harness = await createHarness({ | 523 | const harness = await createHarness({ |
| 524 | serverSettings: { enableLegacyTokenStreaming: false }, | 524 | serverSettings: { responseStreamingMode: "paragraph" }, |
| 525 | }); | 525 | }); |
| 526 | const threadId = asThreadId("thread-1"); | 526 | const threadId = asThreadId("thread-1"); |
| 527 | const oldTurnId = asTurnId("old-buffered-turn"); | 527 | const oldTurnId = asTurnId("old-buffered-turn"); |
| ... | @@ -605,7 +605,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -605,7 +605,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 605 | { source: "an unspecified turn", turnId: undefined }, | 605 | { source: "an unspecified turn", turnId: undefined }, |
| 606 | ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => { | 606 | ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => { |
| 607 | const harness = await createHarness({ | 607 | const harness = await createHarness({ |
| 608 | serverSettings: { enableLegacyTokenStreaming: true }, | 608 | serverSettings: { responseStreamingMode: "token" }, |
| 609 | }); | 609 | }); |
| 610 | const threadId = asThreadId("thread-1"); | 610 | const threadId = asThreadId("thread-1"); |
| 611 | const stoppedTurnId = asTurnId("opencode-stopped-turn"); | 611 | const stoppedTurnId = asTurnId("opencode-stopped-turn"); |
| ... | @@ -2712,7 +2712,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2712,7 +2712,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2712 | }); | 2712 | }); |
| 2713 | 2713 | ||
| 2714 | it("keeps streaming while an async question is pending", async () => { | 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 | const base = { | 2716 | const base = { |
| 2717 | provider: ProviderDriverKind.make("codex"), | 2717 | provider: ProviderDriverKind.make("codex"), |
| 2718 | createdAt: "2026-01-01T00:00:00.000Z", | 2718 | createdAt: "2026-01-01T00:00:00.000Z", |
| ... | @@ -2954,7 +2954,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2954,7 +2954,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2954 | }); | 2954 | }); |
| 2955 | 2955 | ||
| 2956 | it("starts a new streaming assistant message segment after approval", async () => { | 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 | const startedAt = "2026-03-28T07:00:00.000Z"; | 2958 | const startedAt = "2026-03-28T07:00:00.000Z"; |
| 2959 | const pausedAt = "2026-03-28T07:00:01.000Z"; | 2959 | const pausedAt = "2026-03-28T07:00:01.000Z"; |
| 2960 | const resumedAt = "2026-03-28T07:00:02.000Z"; | 2960 | const resumedAt = "2026-03-28T07:00:02.000Z"; |
| ... | @@ -3061,7 +3061,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -3061,7 +3061,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 3061 | }); | 3061 | }); |
| 3062 | 3062 | ||
| 3063 | it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { | 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 | const now = "2026-01-01T00:00:00.000Z"; | 3065 | const now = "2026-01-01T00:00:00.000Z"; |
| 3066 | 3066 | ||
| 3067 | await Effect.runPromise( | 3067 | await Effect.runPromise( |
| ... | @@ -3235,6 +3235,62 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -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 | it("holds paragraphs that finish inside the pacing window and lands them together", async () => { | 3294 | it("holds paragraphs that finish inside the pacing window and lands them together", async () => { |
| 3239 | const harness = await createHarness(); | 3295 | const harness = await createHarness(); |
| 3240 | const codex = ProviderDriverKind.make("codex"); | 3296 | const codex = ProviderDriverKind.make("codex"); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+27-21| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 3 | type AssistantDeliveryMode, | ||
| 4 | CommandId, | 3 | CommandId, |
| 5 | MessageId, | 4 | MessageId, |
| 6 | type OrchestrationEvent, | 5 | type OrchestrationEvent, |
| ... | @@ -14,7 +13,9 @@ import { | ... | @@ -14,7 +13,9 @@ import { |
| 14 | TurnId, | 13 | TurnId, |
| 15 | type OrchestrationCheckpointSummary, | 14 | type OrchestrationCheckpointSummary, |
| 16 | type OrchestrationThreadActivity, | 15 | type OrchestrationThreadActivity, |
| 16 | type ProjectId, | ||
| 17 | type ProviderRuntimeEvent, | 17 | type ProviderRuntimeEvent, |
| 18 | type ResponseStreamingMode, | ||
| 18 | RuntimeRequestId, | 19 | RuntimeRequestId, |
| 19 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 20 | import * as Cache from "effect/Cache"; | 21 | import * as Cache from "effect/Cache"; |
| ... | @@ -1167,7 +1168,19 @@ const make = Effect.gen(function* () { | ... | @@ -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 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( | 1184 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( |
| 1172 | Effect.flatMap((existingText) => | 1185 | Effect.flatMap((existingText) => |
| 1173 | Effect.gen(function* () { | 1186 | Effect.gen(function* () { |
| ... | @@ -1176,9 +1189,13 @@ const make = Effect.gen(function* () { | ... | @@ -1176,9 +1189,13 @@ const make = Effect.gen(function* () { |
| 1176 | onSome: (text) => `${text}${delta}`, | 1189 | onSome: (text) => `${text}${delta}`, |
| 1177 | }); | 1190 | }); |
| 1178 | 1191 | ||
| 1179 | // Deliver finished paragraphs and closed code blocks early so the | 1192 | // Paragraph mode delivers finished paragraphs and closed code blocks |
| 1180 | // user sees progress without token-by-token repaints. | 1193 | // early so the user sees progress without token-by-token repaints. |
| 1181 | const { ready, rest } = splitBufferedAssistantText(nextText); | 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 | const lastDeliveredAt = Option.getOrUndefined( | 1199 | const lastDeliveredAt = Option.getOrUndefined( |
| 1183 | yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), | 1200 | yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), |
| 1184 | ); | 1201 | ); |
| ... | @@ -1757,19 +1774,14 @@ const make = Effect.gen(function* () { | ... | @@ -1757,19 +1774,14 @@ const make = Effect.gen(function* () { |
| 1757 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); | 1774 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); |
| 1758 | } | 1775 | } |
| 1759 | 1776 | ||
| 1760 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( | 1777 | const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); |
| 1761 | serverSettingsService.getSettings, | 1778 | if (streamingMode !== "token") { |
| 1762 | (settings) => | ||
| 1763 | resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming | ||
| 1764 | ? "streaming" | ||
| 1765 | : "buffered", | ||
| 1766 | ); | ||
| 1767 | if (assistantDeliveryMode === "buffered") { | ||
| 1768 | // Pace on the server clock. OpenCode stamps every delta of a part | 1779 | // Pace on the server clock. OpenCode stamps every delta of a part |
| 1769 | // with the part's start time, so the event time cannot measure gaps. | 1780 | // with the part's start time, so the event time cannot measure gaps. |
| 1770 | const spillChunk = yield* appendBufferedAssistantText( | 1781 | const spillChunk = yield* appendBufferedAssistantText( |
| 1771 | assistantMessageId, | 1782 | assistantMessageId, |
| 1772 | assistantDelta, | 1783 | assistantDelta, |
| 1784 | streamingMode, | ||
| 1773 | yield* Clock.currentTimeMillis, | 1785 | yield* Clock.currentTimeMillis, |
| 1774 | ); | 1786 | ); |
| 1775 | if (spillChunk.length > 0) { | 1787 | if (spillChunk.length > 0) { |
| ... | @@ -1807,15 +1819,9 @@ const make = Effect.gen(function* () { | ... | @@ -1807,15 +1819,9 @@ const make = Effect.gen(function* () { |
| 1807 | turnId: pauseForUserTurnId, | 1819 | turnId: pauseForUserTurnId, |
| 1808 | streamingOnly: true, | 1820 | streamingOnly: true, |
| 1809 | }); | 1821 | }); |
| 1810 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( | 1822 | const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); |
| 1811 | serverSettingsService.getSettings, | ||
| 1812 | (settings) => | ||
| 1813 | resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming | ||
| 1814 | ? "streaming" | ||
| 1815 | : "buffered", | ||
| 1816 | ); | ||
| 1817 | const flushedMessageIds = | 1823 | const flushedMessageIds = |
| 1818 | assistantDeliveryMode === "buffered" | 1824 | streamingMode !== "token" |
| 1819 | ? yield* flushBufferedAssistantMessagesForTurn({ | 1825 | ? yield* flushBufferedAssistantMessagesForTurn({ |
| 1820 | event, | 1826 | event, |
| 1821 | threadId: thread.id, | 1827 | threadId: thread.id, |
apps/web/src/components/settings/SettingsPanels.tsx+136-35| ... | @@ -38,6 +38,7 @@ import { | ... | @@ -38,6 +38,7 @@ import { |
| 38 | MIN_PANEL_ANIMATION_DURATION_MS, | 38 | MIN_PANEL_ANIMATION_DURATION_MS, |
| 39 | MIN_PROMPT_FONT_SIZE, | 39 | MIN_PROMPT_FONT_SIZE, |
| 40 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | 40 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 41 | type ResponseStreamingMode, | ||
| 41 | MIN_TERMINAL_FONT_SIZE, | 42 | MIN_TERMINAL_FONT_SIZE, |
| 42 | type QuitConfirmationMode, | 43 | type QuitConfirmationMode, |
| 43 | } from "@t3tools/contracts/settings"; | 44 | } from "@t3tools/contracts/settings"; |
| ... | @@ -94,6 +95,15 @@ import { isMacPlatform } from "../../lib/utils"; | ... | @@ -94,6 +95,15 @@ import { isMacPlatform } from "../../lib/utils"; |
| 94 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; | 95 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; |
| 95 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 96 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 96 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | 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 | import { Button } from "../ui/button"; | 107 | import { Button } from "../ui/button"; |
| 98 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | 108 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 99 | import { | 109 | import { |
| ... | @@ -168,6 +178,18 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s | ... | @@ -168,6 +178,18 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s |
| 168 | none: "None", | 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 | const TIMESTAMP_FORMAT_LABELS = { | 193 | const TIMESTAMP_FORMAT_LABELS = { |
| 172 | locale: "System default", | 194 | locale: "System default", |
| 173 | "12-hour": "12-hour", | 195 | "12-hour": "12-hour", |
| ... | @@ -564,9 +586,8 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -564,9 +586,8 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 564 | ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled | 586 | ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled |
| 565 | ? ["Context window indicator"] | 587 | ? ["Context window indicator"] |
| 566 | : []), | 588 | : []), |
| 567 | ...(settings.enableLegacyTokenStreaming !== | 589 | ...(settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode |
| 568 | DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming | 590 | ? ["Response streaming"] |
| 569 | ? ["Stream token by token"] | ||
| 570 | : []), | 591 | : []), |
| 571 | ...(settings.enableProviderUpdateChecks !== | 592 | ...(settings.enableProviderUpdateChecks !== |
| 572 | DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks | 593 | DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks |
| ... | @@ -640,7 +661,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -640,7 +661,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 640 | settings.fontSizeTerminal, | 661 | settings.fontSizeTerminal, |
| 641 | settings.glassOpacity, | 662 | settings.glassOpacity, |
| 642 | settings.panelAnimationDurationMs, | 663 | settings.panelAnimationDurationMs, |
| 643 | settings.enableLegacyTokenStreaming, | 664 | settings.responseStreamingMode, |
| 644 | settings.enableProviderUpdateChecks, | 665 | settings.enableProviderUpdateChecks, |
| 645 | settings.continueThreadsAfterServerUpdate, | 666 | settings.continueThreadsAfterServerUpdate, |
| 646 | settings.sidebarAutoSettleAfterDays, | 667 | settings.sidebarAutoSettleAfterDays, |
| ... | @@ -744,7 +765,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -744,7 +765,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 744 | sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, | 765 | sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, |
| 745 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, | 766 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, |
| 746 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, | 767 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, |
| 747 | enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, | 768 | responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, |
| 748 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, | 769 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, |
| 749 | continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, | 770 | continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, |
| 750 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | 771 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| ... | @@ -797,6 +818,44 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -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 | function BackgroundActivityAdvancedDialog({ | 859 | function BackgroundActivityAdvancedDialog({ |
| 801 | open, | 860 | open, |
| 802 | onOpenChange, | 861 | onOpenChange, |
| ... | @@ -2014,7 +2073,6 @@ function AutoSettleDaysInput({ | ... | @@ -2014,7 +2073,6 @@ function AutoSettleDaysInput({ |
| 2014 | const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([ | 2073 | const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([ |
| 2015 | "legacy-plan-mode", | 2074 | "legacy-plan-mode", |
| 2016 | "legacy-context-window-indicator", | 2075 | "legacy-context-window-indicator", |
| 2017 | "legacy-token-streaming", | ||
| 2018 | "legacy-sidebar", | 2076 | "legacy-sidebar", |
| 2019 | ]); | 2077 | ]); |
| 2020 | 2078 | ||
| ... | @@ -2082,35 +2140,6 @@ function LegacyFeaturesSection() { | ... | @@ -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 | <SettingsRow | 2143 | <SettingsRow |
| 2115 | {...searchableSetting("legacy-sidebar")} | 2144 | {...searchableSetting("legacy-sidebar")} |
| 2116 | description="Restore per-project thread trees instead of the default flat sidebar." | 2145 | description="Restore per-project thread trees instead of the default flat sidebar." |
| ... | @@ -2144,6 +2173,8 @@ export function GeneralSettingsPanel() { | ... | @@ -2144,6 +2173,8 @@ export function GeneralSettingsPanel() { |
| 2144 | const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null; | 2173 | const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null; |
| 2145 | const hasServerTargets = connectedEnvironments.length > 0; | 2174 | const hasServerTargets = connectedEnvironments.length > 0; |
| 2146 | const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); | 2175 | const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); |
| 2176 | const [tokenStreamingWarningOpen, setTokenStreamingWarningOpen] = useState(false); | ||
| 2177 | const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]); | ||
| 2147 | const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>( | 2178 | const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>( |
| 2148 | readLastEnabledProjectGroupingMode(), | 2179 | readLastEnabledProjectGroupingMode(), |
| 2149 | ); | 2180 | ); |
| ... | @@ -2387,6 +2418,76 @@ export function GeneralSettingsPanel() { | ... | @@ -2387,6 +2418,76 @@ export function GeneralSettingsPanel() { |
| 2387 | </Select> | 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 | <SettingsRow | 2491 | <SettingsRow |
| 2391 | {...searchableSetting("hide-whitespace-changes")} | 2492 | {...searchableSetting("hide-whitespace-changes")} |
| 2392 | description="Set whether the diff panel ignores whitespace-only edits by default." | 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,7 +339,7 @@ describe("settings search targets", () => { |
| 339 | expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true); | 339 | expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true); |
| 340 | expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true); | 340 | expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true); |
| 341 | expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false); | 341 | expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false); |
| 342 | const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!; | 342 | const streaming = getSettingsSearchTargetScope("response-streaming")!; |
| 343 | expect(streaming.scope).toBe("project-defaults"); | 343 | expect(streaming.scope).toBe("project-defaults"); |
| 344 | expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); | 344 | expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); |
| 345 | for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { | 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,6 +261,13 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 261 | to: "/settings/general", | 261 | to: "/settings/general", |
| 262 | searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"], | 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 | id: "hide-whitespace-changes", | 272 | id: "hide-whitespace-changes", |
| 266 | title: "Hide whitespace changes", | 273 | title: "Hide whitespace changes", |
| ... | @@ -398,13 +405,6 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -398,13 +405,6 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 398 | to: "/settings/general", | 405 | to: "/settings/general", |
| 399 | searchTerms: ["composer meter usage tokens circle old"], | 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 | id: "legacy-sidebar", | 409 | id: "legacy-sidebar", |
| 410 | title: "Sidebar (legacy)", | 410 | title: "Sidebar (legacy)", |
packages/contracts/src/orchestration.ts-2| ... | @@ -143,8 +143,6 @@ export const ProviderRequestKind = Schema.Literals([ | ... | @@ -143,8 +143,6 @@ export const ProviderRequestKind = Schema.Literals([ |
| 143 | "mcp-elicitation", | 143 | "mcp-elicitation", |
| 144 | ]); | 144 | ]); |
| 145 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; | 145 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 146 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); | ||
| 147 | export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; | ||
| 148 | export const ProviderApprovalDecision = Schema.Literals([ | 146 | export const ProviderApprovalDecision = Schema.Literals([ |
| 149 | "accept", | 147 | "accept", |
| 150 | "acceptForSession", | 148 | "acceptForSession", |
packages/contracts/src/settings.ts+17-7| ... | @@ -952,6 +952,15 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; | ... | @@ -952,6 +952,15 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; |
| 952 | * background activity, theme. UI, search and the write planner derive | 952 | * background activity, theme. UI, search and the write planner derive |
| 953 | * eligibility from this list, so adding a key here is the whole opt-in. | 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 | export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ | 964 | export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ |
| 956 | "defaultModelSelection", | 965 | "defaultModelSelection", |
| 957 | "defaultRuntimeMode", | 966 | "defaultRuntimeMode", |
| ... | @@ -968,7 +977,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ | ... | @@ -968,7 +977,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ |
| 968 | "sidebarAutoSettleOnMerge", | 977 | "sidebarAutoSettleOnMerge", |
| 969 | "sidebarAutoSettleAfterDays", | 978 | "sidebarAutoSettleAfterDays", |
| 970 | "continueThreadsAfterServerUpdate", | 979 | "continueThreadsAfterServerUpdate", |
| 971 | "enableLegacyTokenStreaming", | 980 | "responseStreamingMode", |
| 972 | ] as const; | 981 | ] as const; |
| 973 | export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; | 982 | export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; |
| 974 | 983 | ||
| ... | @@ -993,16 +1002,17 @@ export const ProjectSettingsOverrides = Schema.Struct({ | ... | @@ -993,16 +1002,17 @@ export const ProjectSettingsOverrides = Schema.Struct({ |
| 993 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), | 1002 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), |
| 994 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), | 1003 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 995 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), | 1004 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 996 | enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), | 1005 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), |
| 997 | } satisfies Record<ProjectScopedServerSettingKey, unknown>); | 1006 | } satisfies Record<ProjectScopedServerSettingKey, unknown>); |
| 998 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; | 1007 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; |
| 999 | 1008 | ||
| 1000 | export const ServerSettings = Schema.Struct({ | 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 | // `enableAssistantStreaming`): decoding drops the old key, so everyone, | 1012 | // `enableAssistantStreaming`): decoding drops the old key, so everyone, |
| 1003 | // including prior opt-ins, resets to the buffered default. | 1013 | // including prior token-streaming opt-ins, resets to the paragraph default. |
| 1004 | enableLegacyTokenStreaming: Schema.Boolean.pipe( | 1014 | responseStreamingMode: ResponseStreamingMode.pipe( |
| 1005 | Schema.withDecodingDefault(Effect.succeed(false)), | 1015 | Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), |
| 1006 | ), | 1016 | ), |
| 1007 | enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 1017 | enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 1008 | // Retain the update-era key; recovery now needs an environment-owned opt-in. | 1018 | // Retain the update-era key; recovery now needs an environment-owned opt-in. |
| ... | @@ -1337,7 +1347,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ | ... | @@ -1337,7 +1347,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ |
| 1337 | 1347 | ||
| 1338 | export const ServerSettingsPatch = Schema.Struct({ | 1348 | export const ServerSettingsPatch = Schema.Struct({ |
| 1339 | // Server settings | 1349 | // Server settings |
| 1340 | enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), | 1350 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), |
| 1341 | enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), | 1351 | enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), |
| 1342 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), | 1352 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 1343 | enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), | 1353 | enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), |