From 100f30ed079484eed33a8e1e982b512293a8b6d5 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 21 Jul 2026 13:19:32 +0100 Subject: [PATCH 1/2] fix(ci): build harness before publishing agent --- .github/workflows/agent-release.yml | 3 +++ packages/agent/package.json | 2 +- packages/agent/src/pi/rpc-client.ts | 11 ++++++++--- pnpm-lock.yaml | 6 +++--- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml index feaf653f2f..bcd48962ab 100644 --- a/.github/workflows/agent-release.yml +++ b/.github/workflows/agent-release.yml @@ -57,6 +57,9 @@ jobs: - name: Build enricher (agent dependency) run: pnpm --filter @posthog/enricher run build + - name: Build harness (agent dependency) + run: pnpm --filter @posthog/harness run build + - name: Build the package run: pnpm --filter agent run build diff --git a/packages/agent/package.json b/packages/agent/package.json index 1f253ca628..cd94555a5c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -145,6 +145,7 @@ "@posthog/shared": "workspace:*", "@posthog/git": "workspace:*", "@posthog/enricher": "workspace:*", + "@posthog/harness": "workspace:*", "@types/bun": "latest", "@types/tar": "^6.1.13", "msw": "^2.12.7", @@ -167,7 +168,6 @@ "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": "^0.208.0", "@opentelemetry/semantic-conventions": "^1.28.0", - "@posthog/harness": "workspace:*", "@types/jsonwebtoken": "^9.0.10", "commander": "^14.0.2", "fflate": "^0.8.2", diff --git a/packages/agent/src/pi/rpc-client.ts b/packages/agent/src/pi/rpc-client.ts index 5806b77d1c..1479a4d6af 100644 --- a/packages/agent/src/pi/rpc-client.ts +++ b/packages/agent/src/pi/rpc-client.ts @@ -11,12 +11,17 @@ import { RpcClient, type RpcClientOptions, } from "@earendil-works/pi-coding-agent"; -import type { PosthogProviderOptions } from "@posthog/harness/extensions/posthog-provider/provider"; import { safePiEnvironment } from "./rpc-environment"; import type { PiModelOption, PiThinkingLevel } from "./types"; export type PiRpcClient = RpcClient; +export interface PiRpcProviderOptions { + region?: "us" | "eu" | "dev"; + apiKey: string; + baseUrl?: string; +} + export async function getAvailableModelsWithThinkingLevels( client: PiRpcClient, ): Promise { @@ -70,7 +75,7 @@ function attachJsonlReader( class SecurePiRpcClient extends RpcClient { constructor( private readonly secureOptions: RpcClientOptions, - private readonly providerOptions: PosthogProviderOptions, + private readonly providerOptions: PiRpcProviderOptions, ) { super(secureOptions); } @@ -164,7 +169,7 @@ export function getPiRpcClientProcess( export type PiRpcClientOptions = Pick & { sessionFile?: string; - providerOptions: PosthogProviderOptions & { apiKey: string }; + providerOptions: PiRpcProviderOptions; }; export function createPiRpcClient(options: PiRpcClientOptions): PiRpcClient { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea5e28250..7474e52bad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -766,9 +766,6 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.28.0 version: 1.39.0 - '@posthog/harness': - specifier: workspace:* - version: link:../harness '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -806,6 +803,9 @@ importers: '@posthog/git': specifier: workspace:* version: link:../git + '@posthog/harness': + specifier: workspace:* + version: link:../harness '@posthog/shared': specifier: workspace:* version: link:../shared From 72f2e42db10ee34138e8ede693bb80ea123c4294 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 21 Jul 2026 13:25:39 +0100 Subject: [PATCH 2/2] fix(glm): stabilize reasoning and context reporting Restore only the persisted config options a resumed session still advertises and still offers a value for; when the agent reports no options (e.g. after compaction) restore nothing rather than push a value it may reject. Define GLM 5.2 reasoning as high/max across session controls and cloud-run validation, normalize its 1M context window when the gateway reports stale metadata, and keep context usage monotonic across incomplete stream and compaction snapshots. --- .../claude/claude-agent.streamed-text.test.ts | 85 +++++++++++- .../agent/src/adapters/claude/claude-agent.ts | 63 ++++++--- .../adapters/claude/session/models.test.ts | 28 ++-- .../src/adapters/claude/session/models.ts | 66 ++++----- .../src/adapters/reasoning-effort.test.ts | 12 ++ packages/agent/src/gateway-models.test.ts | 26 ++++ packages/agent/src/gateway-models.ts | 13 +- .../api-client/src/posthog-client.test.ts | 32 +++++ packages/core/src/sessions/sessionService.ts | 42 ++++-- packages/shared/src/index.ts | 1 + packages/shared/src/sessions.ts | 22 +++ .../sessions/sessionServiceHost.test.ts | 128 ++++++++++++++++++ 12 files changed, 434 insertions(+), 84 deletions(-) diff --git a/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts b/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts index 4bd3d8e13f..de99039542 100644 --- a/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts @@ -149,7 +149,17 @@ function textDelta(sessionId: string, text: string) { }; } -function assistantMessage(sessionId: string, apiId: string, text: string) { +function assistantMessage( + sessionId: string, + apiId: string, + text: string, + usage?: { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; + }, +) { return { type: "assistant", parent_tool_use_id: null, @@ -159,6 +169,20 @@ function assistantMessage(sessionId: string, apiId: string, text: string) { id: apiId, role: "assistant", content: [{ type: "text", text }], + ...(usage ? { usage } : {}), + }, + }; +} + +function compactBoundary(sessionId: string) { + return { + type: "system", + subtype: "compact_boundary", + session_id: sessionId, + uuid: "compact-1", + compact_metadata: { + trigger: "auto", + pre_tokens: 434_000, }, }; } @@ -192,6 +216,23 @@ function messageChunkTexts( .map((update) => update?.content?.text ?? ""); } +function usageUpdates( + calls: ClientMocks["sessionUpdate"]["mock"]["calls"], +): Array<{ used: number; size: number }> { + return calls.flatMap(([call]) => { + const update = ( + call as { + update?: { sessionUpdate?: string; used?: number; size?: number }; + } + ).update; + return update?.sessionUpdate === "usage_update" && + typeof update.used === "number" && + typeof update.size === "number" + ? [{ used: update.used, size: update.size }] + : []; + }); +} + describe("ClaudeAcpAgent.prompt — streamed assistant text wiring", () => { beforeEach(() => { vi.clearAllMocks(); @@ -243,6 +284,48 @@ describe("ClaudeAcpAgent.prompt — streamed assistant text wiring", () => { ]); }); + it("does not replace known context usage with incomplete gateway snapshots", async () => { + const { agent, client } = makeAgent(); + const sessionId = "s-context-usage"; + const { query, input } = installFakeSession(agent, sessionId); + const session = ( + agent as unknown as { + session: { contextUsed?: number; lastContextWindowSize?: number }; + } + ).session; + session.contextUsed = 434_000; + session.lastContextWindowSize = 1_000_000; + vi.mocked(query.getContextUsage).mockRejectedValue( + new Error("context usage unavailable"), + ); + + const promptPromise = agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "continue" }], + }); + await tick(); + + await echoUserMessage(query, input); + await send(query, messageStart(sessionId, "msg-context")); + await send(query, compactBoundary(sessionId)); + await send( + query, + assistantMessage(sessionId, "msg-context", "done", { + input_tokens: 440_000, + output_tokens: 100, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }), + ); + await send(query, resultSuccess(sessionId)); + await promptPromise; + + const updates = usageUpdates(client.sessionUpdate.mock.calls); + expect(updates.length).toBeGreaterThan(0); + expect(updates.every(({ used }) => used >= 434_000)).toBe(true); + expect(updates.every(({ size }) => size === 1_000_000)).toBe(true); + }); + it("keeps the original turn open until a pending steer is consumed", async () => { const { agent, client } = makeAgent(); const sessionId = "s-steer-ordering"; diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 983567d8a5..ff9ece9d8e 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -650,6 +650,21 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; }; + const recordContextUsage = (nextTotal: number): boolean => { + if (nextTotal <= 0 || nextTotal === lastAssistantTotalUsage) { + return false; + } + const knownTotal = Math.max( + lastAssistantTotalUsage ?? 0, + session.contextUsed ?? 0, + ); + if (nextTotal < knownTotal) { + return false; + } + lastAssistantTotalUsage = nextTotal; + return true; + }; + const resetTurnScratch = () => { lastAssistantTotalUsage = null; lastRefusalExplanation = null; @@ -831,16 +846,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent { fetchContextUsedTokens(query, this.logger), cancelController.signal, ); - lastAssistantTotalUsage = - usedTokens.result === "success" ? (usedTokens.value ?? 0) : 0; - await this.client.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "usage_update", - used: lastAssistantTotalUsage, - size: windowSize(), - }, - }); + if (usedTokens.result === "success" && usedTokens.value != null) { + lastAssistantTotalUsage = usedTokens.value; + session.contextUsed = usedTokens.value; + session.contextSize = windowSize(); + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "usage_update", + used: lastAssistantTotalUsage, + size: windowSize(), + }, + }); + } } if (message.subtype === "commands_changed") { session.knownSlashCommands = collectKnownSlashCommands( @@ -1200,8 +1218,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { lastStreamUsage.cache_read_input_tokens + lastStreamUsage.cache_creation_input_tokens; - if (nextTotal !== lastAssistantTotalUsage) { - lastAssistantTotalUsage = nextTotal; + if (recordContextUsage(nextTotal)) { await this.client.sessionUpdate({ sessionId, update: { @@ -1296,21 +1313,23 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cache_read_input_tokens: number | null; cache_creation_input_tokens: number | null; }; - lastAssistantTotalUsage = + const nextTotal = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0); - await this.client.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "usage_update", - used: lastAssistantTotalUsage, - size: windowSize(), - cost: null, - }, - }); + if (recordContextUsage(nextTotal)) { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "usage_update", + used: nextTotal, + size: windowSize(), + cost: null, + }, + }); + } } const result = await handleUserAssistantMessage(message, context); diff --git a/packages/agent/src/adapters/claude/session/models.test.ts b/packages/agent/src/adapters/claude/session/models.test.ts index c4797ff76a..66ffe81c98 100644 --- a/packages/agent/src/adapters/claude/session/models.test.ts +++ b/packages/agent/src/adapters/claude/session/models.test.ts @@ -95,6 +95,13 @@ describe("model capability flags", () => { xhighEffort: false, mcpInjection: false, }, + { + modelId: "@cf/zai-org/glm-5.2", + oneMContext: false, + effort: true, + xhighEffort: false, + mcpInjection: true, + }, ])( "$modelId capability flags", ({ modelId, oneMContext, effort, xhighEffort, mcpInjection }) => { @@ -118,6 +125,7 @@ describe("resolveEffortForModel", () => { ["claude-opus-4-7", undefined, "high"], ["claude-sonnet-4-6", undefined, "high"], ["claude-sonnet-5", undefined, "high"], + ["@cf/zai-org/glm-5.2", undefined, "high"], // Models without effort support stay unset (SDK disables thinking). ["claude-haiku-4-5", undefined, undefined], ["claude-opus-4-6", undefined, undefined], @@ -139,20 +147,12 @@ describe("getEffortOptions", () => { expect(getEffortOptions("claude-opus-4-6")).toBeNull(); }); - it("returns low/medium/high for effort-supporting models", () => { - const opts = getEffortOptions("claude-sonnet-4-6"); - expect(opts?.map((o) => o.value)).toEqual(["low", "medium", "high"]); - }); - - it("appends xhigh and max for xhigh-supporting models", () => { - const opts = getEffortOptions("claude-opus-4-7"); - expect(opts?.map((o) => o.value)).toEqual([ - "low", - "medium", - "high", - "xhigh", - "max", - ]); + it.each([ + ["claude-sonnet-4-6", ["low", "medium", "high"]], + ["claude-opus-4-7", ["low", "medium", "high", "xhigh", "max"]], + ["@cf/zai-org/glm-5.2", ["high", "max"]], + ])("returns the exact effort levels for %s", (modelId, expected) => { + expect(getEffortOptions(modelId)?.map((o) => o.value)).toEqual(expected); }); }); diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 49e4482e1d..fa30f3eadc 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -33,23 +33,27 @@ export function supports1MContext(modelId: string): boolean { return MODELS_WITH_1M_CONTEXT.has(modelId); } -const MODELS_WITH_EFFORT = new Set([ - "claude-opus-4-7", - "claude-opus-4-8", - "claude-sonnet-4-6", - "claude-sonnet-5", - "claude-fable-5", -]); - -const MODELS_WITH_XHIGH_EFFORT = new Set([ - "claude-opus-4-7", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-fable-5", -]); +const STANDARD_EFFORT_LEVELS: readonly EffortLevel[] = [ + "low", + "medium", + "high", +]; +const EXTENDED_EFFORT_LEVELS: readonly EffortLevel[] = [ + ...STANDARD_EFFORT_LEVELS, + "xhigh", + "max", +]; +const MODEL_EFFORT_LEVELS: Readonly> = { + "claude-opus-4-7": EXTENDED_EFFORT_LEVELS, + "claude-opus-4-8": EXTENDED_EFFORT_LEVELS, + "claude-sonnet-4-6": STANDARD_EFFORT_LEVELS, + "claude-sonnet-5": EXTENDED_EFFORT_LEVELS, + "claude-fable-5": EXTENDED_EFFORT_LEVELS, + "@cf/zai-org/glm-5.2": ["high", "max"], +}; export function supportsEffort(modelId: string): boolean { - return MODELS_WITH_EFFORT.has(modelId); + return MODEL_EFFORT_LEVELS[modelId] !== undefined; } export function resolveEffortForModel( @@ -61,7 +65,7 @@ export function resolveEffortForModel( } export function supportsXhighEffort(modelId: string): boolean { - return MODELS_WITH_XHIGH_EFFORT.has(modelId); + return MODEL_EFFORT_LEVELS[modelId]?.includes("xhigh") ?? false; } const MODELS_TO_EXCLUDE_MCP_TOOLS = new Set(["claude-haiku-4-5"]); @@ -82,27 +86,23 @@ export function fastModeStateEnabled(state: string | undefined): boolean { } interface EffortOption { - value: string; + value: EffortLevel; name: string; } -export function getEffortOptions(modelId: string): EffortOption[] | null { - if (!supportsEffort(modelId)) return null; - - const options: EffortOption[] = [ - { value: "low", name: "Low" }, - { value: "medium", name: "Medium" }, - { value: "high", name: "High" }, - ]; - - if (supportsXhighEffort(modelId)) { - options.push( - { value: "xhigh", name: "Extra High" }, - { value: "max", name: "Max" }, - ); - } +const EFFORT_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; - return options; +export function getEffortOptions(modelId: string): EffortOption[] | null { + const levels = MODEL_EFFORT_LEVELS[modelId]; + return ( + levels?.map((value) => ({ value, name: EFFORT_LABELS[value] })) ?? null + ); } // Model alias resolution — lets callers use human-friendly aliases like diff --git a/packages/agent/src/adapters/reasoning-effort.test.ts b/packages/agent/src/adapters/reasoning-effort.test.ts index 26248716ee..cc559f486b 100644 --- a/packages/agent/src/adapters/reasoning-effort.test.ts +++ b/packages/agent/src/adapters/reasoning-effort.test.ts @@ -39,4 +39,16 @@ describe("isSupportedReasoningEffort", () => { isSupportedReasoningEffort("claude", "claude-sonnet-4-6", "xhigh"), ).toBe(false); }); + + it.each([ + ["high", true], + ["max", true], + ["low", false], + ["medium", false], + ["xhigh", false], + ])("validates GLM 5.2 effort %s", (effort, expected) => { + expect( + isSupportedReasoningEffort("claude", "@cf/zai-org/glm-5.2", effort), + ).toBe(expected); + }); }); diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts index a92614c462..0c3b7a764a 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -239,6 +239,32 @@ describe("gateway models cache", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(cached[0]?.allowed).toBe(false); }); + + it("corrects stale GLM 5.2 context-window metadata", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + object: "list", + data: [ + { + id: "@cf/zai-org/glm-5.2", + owned_by: "cloudflare", + context_window: 128_000, + supports_streaming: true, + supports_vision: false, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const models = await fetchGatewayModels({ + gatewayUrl: "https://gateway.glm-context-test", + }); + + expect(models[0]?.context_window).toBe(1_000_000); + }); }); describe("isCloudflareModel", () => { diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 9faf9e5f7d..3acb52d534 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -71,6 +71,10 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // the callers fall through to `return []`. const GATEWAY_FETCH_TIMEOUT_MS = 10_000; +const MODEL_CONTEXT_WINDOW_OVERRIDES: Readonly> = { + "@cf/zai-org/glm-5.2": 1_000_000, +}; + // Restriction marks are identity-scoped (free-tier marks are authed-only and // differ per org), so cache entries are keyed on the exact token — an org // switch in the same process must never be served the old org's marks. A @@ -124,7 +128,14 @@ export async function fetchGatewayModels( const data = (await response.json()) as GatewayModelsResponse; const models = (data.data ?? []) .filter((m) => !isBlockedModelId(m.id)) - .map((m) => ({ ...m, allowed: m.allowed !== false })); + .map((m) => ({ + ...m, + context_window: Math.max( + m.context_window, + MODEL_CONTEXT_WINDOW_OVERRIDES[m.id] ?? 0, + ), + allowed: m.allowed !== false, + })); gatewayModelsCache = { models, expiry: Date.now() + CACHE_TTL, diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 472a8eeb74..927fd90fc2 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -169,6 +169,38 @@ describe("PostHogAPIClient", () => { expect(post).not.toHaveBeenCalled(); }); + it.each(["high", "max"] as const)( + "forwards supported GLM 5.2 reasoning effort %s", + async (reasoningLevel) => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + const post = vi.fn().mockResolvedValue({ id: "run-123" }); + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await client.runTaskInCloud("task-123", "feature/glm-effort", { + adapter: "claude", + model: "@cf/zai-org/glm-5.2", + reasoningLevel, + }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + body: expect.objectContaining({ + runtime_adapter: "claude", + model: "@cf/zai-org/glm-5.2", + reasoning_effort: reasoningLevel, + }), + }), + ); + }, + ); + it("rejects unsupported minimal reasoning effort for cloud runs", async () => { const client = new PostHogAPIClient( "http://localhost:8000", diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 2879033ee8..4bda00fe47 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -24,6 +24,7 @@ import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, + isPersistedOptionSupported, isRateLimitError, isTransientUpstreamError, mergeConfigOptions, @@ -2022,18 +2023,33 @@ export class SessionService { }); if (result) { - // Cast and merge live configOptions with persisted values. - // Fall back to persisted options if the agent doesn't return any - // (e.g. after session compaction). - let configOptions = result.configOptions as + const liveConfigOptions = result.configOptions as | SessionConfigOption[] | undefined; - if (configOptions && persistedConfigOptions) { - configOptions = mergeConfigOptions( - configOptions, - persistedConfigOptions, - ); - } else if (!configOptions) { + + // Only restore persisted options the resumed session still supports: + // it must advertise an option with the same id and still offer the + // persisted value (see isPersistedOptionSupported). Without live + // options (e.g. after session compaction) we can't confirm support, so + // we restore nothing rather than push a value the agent may reject — + // the same failure this guard exists to prevent. + const restorableConfigOptions = + liveConfigOptions && persistedConfigOptions + ? persistedConfigOptions.filter((persistedOption) => + isPersistedOptionSupported(persistedOption, liveConfigOptions), + ) + : []; + + // Merge only the restorable persisted values into the live options so + // the stored and displayed config never shows a setting the resumed + // agent rejected. Fall back to persisted options for display when the + // agent returns none (nothing is pushed to the server in that case). + let configOptions: SessionConfigOption[] | undefined; + if (liveConfigOptions) { + configOptions = restorableConfigOptions.length + ? mergeConfigOptions(liveConfigOptions, restorableConfigOptions) + : liveConfigOptions; + } else { configOptions = persistedConfigOptions ?? undefined; } @@ -2048,10 +2064,10 @@ export class SessionService { this.d.setPersistedConfigOptions(taskRunId, configOptions); } - // Restore persisted config options to server in parallel - if (persistedConfigOptions) { + // Restore supported persisted config options to server in parallel + if (restorableConfigOptions.length) { await Promise.all( - persistedConfigOptions.map((opt) => + restorableConfigOptions.map((opt) => this.d.trpc.agent.setConfigOption .mutate({ sessionId: taskRunId, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2158b3eae3..ab8daaa6ea 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -235,6 +235,7 @@ export { flattenSelectOptions, getConfigOptionByCategory, getCurrentModeFromConfigOptions, + isPersistedOptionSupported, isSelectGroup, mergeConfigOptions, type OptimisticItem, diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index fd4fca99c3..ca64dcb01e 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -163,6 +163,28 @@ export function mergeConfigOptions( }); } +/** + * Whether a persisted config option can be restored into a resumed session's + * live options. The live session must still advertise an option with the same + * id and type, and — for selects — must still offer the persisted value. + * Matching by id alone would restore a value the resumed agent dropped (e.g. a + * reasoning level the resumed model no longer supports), which the server + * rejects and which leaves the UI showing a setting that never took effect. + */ +export function isPersistedOptionSupported( + persisted: SessionConfigOption, + liveOptions: SessionConfigOption[], +): boolean { + const live = liveOptions.find((opt) => opt.id === persisted.id); + if (!live || live.type !== persisted.type) return false; + if (live.type === "select") { + return flattenSelectOptions(live.options).some( + (opt) => opt.value === persisted.currentValue, + ); + } + return true; +} + export function getConfigOptionByCategory( configOptions: SessionConfigOption[] | undefined, category: string, diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index 28a710fc2c..cf9ebc06dc 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -7984,6 +7984,134 @@ describe("SessionService", () => { expect(mockTrpcAgent.reconnect.mutate).toHaveBeenCalled(); }); + it("does not restore persisted options unsupported by the resumed session", async () => { + const service = getSessionService(); + const modelOption: SessionConfigOption = { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "@cf/zai-org/glm-5.2", + options: [{ value: "@cf/zai-org/glm-5.2", name: "GLM 5.2" }], + }; + const effortOption: SessionConfigOption = { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "medium", + options: [{ value: "medium", name: "Medium" }], + }; + const mockSession = createMockSession({ + status: "error", + logUrl: "https://logs.example.com/run-123", + }); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession); + mockSessionConfigStore.getPersistedConfigOptions.mockReturnValue([ + modelOption, + effortOption, + ]); + mockTrpcAgent.reconnect.mutate.mockResolvedValue({ + sessionId: "run-123", + channel: "agent-event:run-123", + configOptions: [modelOption], + }); + mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true }); + mockTrpcLogs.readLocalLogs.query.mockResolvedValue(""); + + await service.clearSessionError("task-123", "/repo"); + + expect(mockTrpcAgent.setConfigOption.mutate).toHaveBeenCalledTimes(1); + expect(mockTrpcAgent.setConfigOption.mutate).toHaveBeenCalledWith({ + sessionId: "run-123", + configId: "model", + value: "@cf/zai-org/glm-5.2", + }); + expect( + mockSessionConfigStore.setPersistedConfigOptions, + ).toHaveBeenCalledWith("run-123", [modelOption]); + }); + + it("drops a persisted value the resumed option no longer offers", async () => { + const service = getSessionService(); + // Same option id, but the resumed model only offers high/max — the + // persisted "medium" is stale and must not be restored or displayed. + const staleEffort: SessionConfigOption = { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "medium", + options: [{ value: "medium", name: "Medium" }], + }; + const liveEffort: SessionConfigOption = { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "high", + options: [ + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }; + const mockSession = createMockSession({ + status: "error", + logUrl: "https://logs.example.com/run-123", + }); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession); + mockSessionConfigStore.getPersistedConfigOptions.mockReturnValue([ + staleEffort, + ]); + mockTrpcAgent.reconnect.mutate.mockResolvedValue({ + sessionId: "run-123", + channel: "agent-event:run-123", + configOptions: [liveEffort], + }); + mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true }); + mockTrpcLogs.readLocalLogs.query.mockResolvedValue(""); + + await service.clearSessionError("task-123", "/repo"); + + expect(mockTrpcAgent.setConfigOption.mutate).not.toHaveBeenCalled(); + // Stored config keeps the live value, never the rejected "medium". + expect( + mockSessionConfigStore.setPersistedConfigOptions, + ).toHaveBeenCalledWith("run-123", [liveEffort]); + }); + + it("restores nothing when the resumed session reports no options", async () => { + const service = getSessionService(); + const effortOption: SessionConfigOption = { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "medium", + options: [{ value: "medium", name: "Medium" }], + }; + const mockSession = createMockSession({ + status: "error", + logUrl: "https://logs.example.com/run-123", + }); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession); + mockSessionConfigStore.getPersistedConfigOptions.mockReturnValue([ + effortOption, + ]); + // Reconnect omits configOptions (e.g. after compaction): support can't + // be confirmed, so persisted options must not be pushed to the server. + mockTrpcAgent.reconnect.mutate.mockResolvedValue({ + sessionId: "run-123", + channel: "agent-event:run-123", + }); + mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true }); + mockTrpcLogs.readLocalLogs.query.mockResolvedValue(""); + + await service.clearSessionError("task-123", "/repo"); + + expect(mockTrpcAgent.setConfigOption.mutate).not.toHaveBeenCalled(); + }); + it("keeps the in-memory transcript when the log read returns nothing", async () => { const service = getSessionService(); const previousEvents = [