authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-13 20:49:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 20:49:13-07:00
log1bbca0e78202c8ece73351fccd29f3c99070b82e
treeb0edaba1744b2f4cd02469373c07e912e9b61496
parent3b75e607eb909522a8f5562fb40e44efc72bb66c
signature Signed by PGP key B5690EEEBB952194

feat(settings): choose how responses stream, with a warning on legacy token mode (#11678)

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", () => {
465465 });
466466
467467 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 },
470470 ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => {
471471 const harness = await createHarness({
472 serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming },
472 serverSettings: { responseStreamingMode: settings.responseStreamingMode },
473473 });
474474 const threadId = asThreadId("thread-1");
475475 const turnId = asTurnId("opencode-aborted-turn");
......@@ -521,7 +521,7 @@ describe("ProviderRuntimeIngestion", () => {
521521 "finalizes old buffered text on late %s without stopping the newer turn",
522522 async (terminalType) => {
523523 const harness = await createHarness({
524 serverSettings: { enableLegacyTokenStreaming: false },
524 serverSettings: { responseStreamingMode: "paragraph" },
525525 });
526526 const threadId = asThreadId("thread-1");
527527 const oldTurnId = asTurnId("old-buffered-turn");
......@@ -605,7 +605,7 @@ describe("ProviderRuntimeIngestion", () => {
605605 { source: "an unspecified turn", turnId: undefined },
606606 ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => {
607607 const harness = await createHarness({
608 serverSettings: { enableLegacyTokenStreaming: true },
608 serverSettings: { responseStreamingMode: "token" },
609609 });
610610 const threadId = asThreadId("thread-1");
611611 const stoppedTurnId = asTurnId("opencode-stopped-turn");
......@@ -2712,7 +2712,7 @@ describe("ProviderRuntimeIngestion", () => {
27122712 });
27132713
27142714 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" } });
27162716 const base = {
27172717 provider: ProviderDriverKind.make("codex"),
27182718 createdAt: "2026-01-01T00:00:00.000Z",
......@@ -2954,7 +2954,7 @@ describe("ProviderRuntimeIngestion", () => {
29542954 });
29552955
29562956 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" } });
29582958 const startedAt = "2026-03-28T07:00:00.000Z";
29592959 const pausedAt = "2026-03-28T07:00:01.000Z";
29602960 const resumedAt = "2026-03-28T07:00:02.000Z";
......@@ -3061,7 +3061,7 @@ describe("ProviderRuntimeIngestion", () => {
30613061 });
30623062
30633063 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" } });
30653065 const now = "2026-01-01T00:00:00.000Z";
30663066
30673067 await Effect.runPromise(
......@@ -3235,6 +3235,62 @@ describe("ProviderRuntimeIngestion", () => {
32353235 );
32363236 });
32373237
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
32383294 it("holds paragraphs that finish inside the pacing window and lands them together", async () => {
32393295 const harness = await createHarness();
32403296 const codex = ProviderDriverKind.make("codex");
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+27-21
......@@ -1,6 +1,5 @@
11import {
22 ApprovalRequestId,
3 type AssistantDeliveryMode,
43 CommandId,
54 MessageId,
65 type OrchestrationEvent,
......@@ -14,7 +13,9 @@ import {
1413 TurnId,
1514 type OrchestrationCheckpointSummary,
1615 type OrchestrationThreadActivity,
16 type ProjectId,
1717 type ProviderRuntimeEvent,
18 type ResponseStreamingMode,
1819 RuntimeRequestId,
1920} from "@t3tools/contracts";
2021import * as Cache from "effect/Cache";
......@@ -1167,7 +1168,19 @@ const make = Effect.gen(function* () {
11671168 });
11681169 });
11691170
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 ) =>
11711184 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
11721185 Effect.flatMap((existingText) =>
11731186 Effect.gen(function* () {
......@@ -1176,9 +1189,13 @@ const make = Effect.gen(function* () {
11761189 onSome: (text) => `${text}${delta}`,
11771190 });
11781191
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 };
11821199 const lastDeliveredAt = Option.getOrUndefined(
11831200 yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId),
11841201 );
......@@ -1757,19 +1774,14 @@ const make = Effect.gen(function* () {
17571774 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
17581775 }
17591776
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") {
17681779 // Pace on the server clock. OpenCode stamps every delta of a part
17691780 // with the part's start time, so the event time cannot measure gaps.
17701781 const spillChunk = yield* appendBufferedAssistantText(
17711782 assistantMessageId,
17721783 assistantDelta,
1784 streamingMode,
17731785 yield* Clock.currentTimeMillis,
17741786 );
17751787 if (spillChunk.length > 0) {
......@@ -1807,15 +1819,9 @@ const make = Effect.gen(function* () {
18071819 turnId: pauseForUserTurnId,
18081820 streamingOnly: true,
18091821 });
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);
18171823 const flushedMessageIds =
1818 assistantDeliveryMode === "buffered"
1824 streamingMode !== "token"
18191825 ? yield* flushBufferedAssistantMessagesForTurn({
18201826 event,
18211827 threadId: thread.id,
apps/web/src/components/settings/SettingsPanels.tsx+136-35
......@@ -38,6 +38,7 @@ import {
3838 MIN_PANEL_ANIMATION_DURATION_MS,
3939 MIN_PROMPT_FONT_SIZE,
4040 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
41 type ResponseStreamingMode,
4142 MIN_TERMINAL_FONT_SIZE,
4243 type QuitConfirmationMode,
4344} from "@t3tools/contracts/settings";
......@@ -94,6 +95,15 @@ import { isMacPlatform } from "../../lib/utils";
9495import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
9596import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
9697import { formatRelativeTimeLabel } from "../../timestampFormat";
98import {
99 AlertDialog,
100 AlertDialogClose,
101 AlertDialogDescription,
102 AlertDialogFooter,
103 AlertDialogHeader,
104 AlertDialogPopup,
105 AlertDialogTitle,
106} from "../ui/alert-dialog";
97107import { Button } from "../ui/button";
98108import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
99109import {
......@@ -168,6 +178,18 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s
168178 none: "None",
169179};
170180
181const 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
187const 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
171193const TIMESTAMP_FORMAT_LABELS = {
172194 locale: "System default",
173195 "12-hour": "12-hour",
......@@ -564,9 +586,8 @@ export function useSettingsRestore(onRestored?: () => void) {
564586 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
565587 ? ["Context window indicator"]
566588 : []),
567 ...(settings.enableLegacyTokenStreaming !==
568 DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming
569 ? ["Stream token by token"]
589 ...(settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode
590 ? ["Response streaming"]
570591 : []),
571592 ...(settings.enableProviderUpdateChecks !==
572593 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks
......@@ -640,7 +661,7 @@ export function useSettingsRestore(onRestored?: () => void) {
640661 settings.fontSizeTerminal,
641662 settings.glassOpacity,
642663 settings.panelAnimationDurationMs,
643 settings.enableLegacyTokenStreaming,
664 settings.responseStreamingMode,
644665 settings.enableProviderUpdateChecks,
645666 settings.continueThreadsAfterServerUpdate,
646667 settings.sidebarAutoSettleAfterDays,
......@@ -744,7 +765,7 @@ export function useSettingsRestore(onRestored?: () => void) {
744765 sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows,
745766 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
746767 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
747 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
768 responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
748769 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
749770 continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
750771 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
......@@ -797,6 +818,44 @@ export function useSettingsRestore(onRestored?: () => void) {
797818 };
798819}
799820
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 */
825function 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
800859function BackgroundActivityAdvancedDialog({
801860 open,
802861 onOpenChange,
......@@ -2014,7 +2073,6 @@ function AutoSettleDaysInput({
20142073const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([
20152074 "legacy-plan-mode",
20162075 "legacy-context-window-indicator",
2017 "legacy-token-streaming",
20182076 "legacy-sidebar",
20192077]);
20202078
......@@ -2082,35 +2140,6 @@ function LegacyFeaturesSection() {
20822140 />
20832141 }
20842142 />
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 />
21142143 <SettingsRow
21152144 {...searchableSetting("legacy-sidebar")}
21162145 description="Restore per-project thread trees instead of the default flat sidebar."
......@@ -2144,6 +2173,8 @@ export function GeneralSettingsPanel() {
21442173 const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null;
21452174 const hasServerTargets = connectedEnvironments.length > 0;
21462175 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
2176 const [tokenStreamingWarningOpen, setTokenStreamingWarningOpen] = useState(false);
2177 const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]);
21472178 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
21482179 readLastEnabledProjectGroupingMode(),
21492180 );
......@@ -2387,6 +2418,76 @@ export function GeneralSettingsPanel() {
23872418 </Select>
23882419 }
23892420 />
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 />
23902491 <SettingsRow
23912492 {...searchableSetting("hide-whitespace-changes")}
23922493 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", () => {
339339 expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true);
340340 expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true);
341341 expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false);
342 const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!;
342 const streaming = getSettingsSearchTargetScope("response-streaming")!;
343343 expect(streaming.scope).toBe("project-defaults");
344344 expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true);
345345 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 = [
261261 to: "/settings/general",
262262 searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],
263263 },
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 },
264271 {
265272 id: "hide-whitespace-changes",
266273 title: "Hide whitespace changes",
......@@ -398,13 +405,6 @@ export const SETTINGS_SEARCH_ITEMS = [
398405 to: "/settings/general",
399406 searchTerms: ["composer meter usage tokens circle old"],
400407 },
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 },
408408 {
409409 id: "legacy-sidebar",
410410 title: "Sidebar (legacy)",
packages/contracts/src/orchestration.ts-2
......@@ -143,8 +143,6 @@ export const ProviderRequestKind = Schema.Literals([
143143 "mcp-elicitation",
144144]);
145145export type ProviderRequestKind = typeof ProviderRequestKind.Type;
146export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
147export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;
148146export const ProviderApprovalDecision = Schema.Literals([
149147 "accept",
150148 "acceptForSession",
packages/contracts/src/settings.ts+17-7
......@@ -952,6 +952,15 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
952952 * background activity, theme. UI, search and the write planner derive
953953 * eligibility from this list, so adding a key here is the whole opt-in.
954954 */
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 */
961export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]);
962export type ResponseStreamingMode = typeof ResponseStreamingMode.Type;
963
955964export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
956965 "defaultModelSelection",
957966 "defaultRuntimeMode",
......@@ -968,7 +977,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
968977 "sidebarAutoSettleOnMerge",
969978 "sidebarAutoSettleAfterDays",
970979 "continueThreadsAfterServerUpdate",
971 "enableLegacyTokenStreaming",
980 "responseStreamingMode",
972981] as const;
973982export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number];
974983
......@@ -993,16 +1002,17 @@ export const ProjectSettingsOverrides = Schema.Struct({
9931002 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
9941003 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
9951004 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
996 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
1005 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
9971006} satisfies Record<ProjectScopedServerSettingKey, unknown>);
9981007export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;
9991008
10001009export 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
10021012 // `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)),
10061016 ),
10071017 enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
10081018 // Retain the update-era key; recovery now needs an environment-owned opt-in.
......@@ -1337,7 +1347,7 @@ const OpenCodeSettingsPatch = Schema.Struct({
13371347
13381348export const ServerSettingsPatch = Schema.Struct({
13391349 // Server settings
1340 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
1350 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
13411351 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
13421352 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
13431353 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),