From 92019c9f8fceb76ca0cccd9b8786f4d938d1a555 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:33:26 +0800 Subject: [PATCH 1/2] fix: preserve Pi and OpenCode context state --- docs/managed-workspace-runtime.md | 22 ++ src/webui/routes/workspaces-quickchat.spec.ts | 2 +- .../agent-credential-readiness.spec.ts | 52 ++++- src/workspaces/agent-credential-readiness.ts | 33 ++- src/workspaces/credential-injection.spec.ts | 2 +- src/workspaces/credential-injection.ts | 12 +- .../components/workspace/WebPiView.spec.tsx | 96 +++++++++ ui/src/components/workspace/WebPiView.tsx | 41 +++- .../workspace/WorkspaceAIConfigModal.spec.tsx | 197 ++++++++++++++++++ .../workspace/WorkspaceAIConfigModal.tsx | 84 ++++++-- ui/src/components/workspace/workspaces.css | 4 + 11 files changed, 510 insertions(+), 35 deletions(-) create mode 100644 ui/src/components/workspace/WebPiView.spec.tsx create mode 100644 ui/src/components/workspace/WorkspaceAIConfigModal.spec.tsx diff --git a/docs/managed-workspace-runtime.md b/docs/managed-workspace-runtime.md index f64294617..463cb1851 100644 --- a/docs/managed-workspace-runtime.md +++ b/docs/managed-workspace-runtime.md @@ -202,6 +202,28 @@ Do not add external-Pi version probing or upgrade UX to preserve flags used by the packaged runtime. Compatibility for the packaged app is maintained by pinning and upgrading the bundled Pi with the OpenAlice release. +### Pi and OpenCode context limits + +Workspace provider overrides write the model context limit into both runtime +formats: `provider.models..limit.context` for OpenCode and +`providers.workspace.models[].contextWindow` for Pi. Unknown custom and local +models default to **256K**, not 1M. Larger values remain explicit user choices; +the provider connection probe cannot prove that a local backend has enough +memory for a future long prefill. + +The Workspace config file is the source of truth for this model-specific +choice. Re-selecting the same vault credential and model must preserve the +existing Workspace context limit. Selecting a different credential or model +starts from the conservative default instead of carrying an unrelated 1M +setting across providers. + +Pi and OpenCode load provider state at process startup. Saving a new context +limit does not resize a running process: the UI must name affected running +Sessions and tell the user to pause and resume them. WebPi follows new output +only while the reader is already near the transcript tail; polling, retries, +and streaming updates must never steal the scroll position from someone +reading older messages. + ### Codex interactive permissions OpenAlice launches interactive Codex TUI sessions with explicit diff --git a/src/webui/routes/workspaces-quickchat.spec.ts b/src/webui/routes/workspaces-quickchat.spec.ts index 4d4bb52de..519b48dea 100644 --- a/src/webui/routes/workspaces-quickchat.spec.ts +++ b/src/webui/routes/workspaces-quickchat.spec.ts @@ -185,7 +185,7 @@ describe('POST /quick-chat — loginless credential injection', () => { expect(cred.apiKey).toBe('sk-oa'); expect(cred.wireShape).toBe('openai-chat'); expect(cred.model).toBe('gpt-5.5'); // vendor flagship — no lastModel yet - expect(cred.contextWindow).toBe(1_000_000); + expect(cred.contextWindow).toBe(256_000); // model remembered on the cred for next time expect(vi.mocked(setCredentialLastModel)).toHaveBeenCalledWith('openai-1', 'gpt-5.5'); expect(spawn).toHaveBeenCalledOnce(); diff --git a/src/workspaces/agent-credential-readiness.spec.ts b/src/workspaces/agent-credential-readiness.spec.ts index 65e8e81b6..697ba7fcb 100644 --- a/src/workspaces/agent-credential-readiness.spec.ts +++ b/src/workspaces/agent-credential-readiness.spec.ts @@ -100,11 +100,61 @@ describe('agent credential readiness', () => { apiKey: 'sk-oa', model: 'gpt-5.5', wireShape: 'openai-chat', - contextWindow: 1_000_000, + contextWindow: 256_000, })); expect(vi.mocked(setCredentialLastModel)).toHaveBeenCalledWith('openai-1', 'gpt-5.5'); }); + it.each(['opencode', 'pi'])('preserves a matching %s workspace context when its credential is explicitly reselected', async (agentId) => { + const a = adapter(agentId, { + baseUrl: null, + apiKey: 'sk-oa', + model: 'gpt-5.5', + wireShape: 'openai-chat', + contextWindow: 128_000, + }); + vi.mocked(readCredentials).mockResolvedValue({ 'openai-1': openaiKey }); + + await ensureAgentCredentialReady({ + meta, + agentId, + adapter: a, + pickedCredentialSlug: 'openai-1', + }); + + expect(a.writeAiConfig).toHaveBeenCalledWith('/tmp/ws-1', expect.objectContaining({ + apiKey: 'sk-oa', + model: 'gpt-5.5', + contextWindow: 128_000, + })); + }); + + it('does not carry a previous provider context into a different credential', async () => { + const a = adapter('pi', { + baseUrl: null, + apiKey: 'sk-oa', + model: 'gpt-5.5', + wireShape: 'openai-chat', + contextWindow: 1_000_000, + }); + vi.mocked(readCredentials).mockResolvedValue({ + 'openai-1': openaiKey, + 'openai-2': { ...openaiKey, apiKey: 'sk-second' }, + }); + + await ensureAgentCredentialReady({ + meta, + agentId: 'pi', + adapter: a, + pickedCredentialSlug: 'openai-2', + }); + + expect(a.writeAiConfig).toHaveBeenCalledWith('/tmp/ws-1', expect.objectContaining({ + apiKey: 'sk-second', + contextWindow: 256_000, + })); + }); + it('does not treat a custom credential without a remembered model as injectable', async () => { const a = adapter('opencode', null); vi.mocked(readCredentials).mockResolvedValue({ diff --git a/src/workspaces/agent-credential-readiness.ts b/src/workspaces/agent-credential-readiness.ts index 6d3803f51..3a63fab01 100644 --- a/src/workspaces/agent-credential-readiness.ts +++ b/src/workspaces/agent-credential-readiness.ts @@ -85,6 +85,22 @@ function injectableCredentials( return out } +function preserveMatchingWorkspaceContext( + next: WorkspaceAiCred, + current: WorkspaceAiCred | null, + sameCredential: boolean, +): WorkspaceAiCred { + const currentContext = current?.contextWindow + if ( + !sameCredential || + current?.model !== next.model || + typeof currentContext !== 'number' || + !Number.isFinite(currentContext) || + currentContext <= 0 + ) return next + return { ...next, contextWindow: currentContext } +} + async function readWorkspaceConfig(meta: WorkspaceMeta, adapter: CliAdapter | undefined): Promise { if (!adapter?.readAiConfig) return null return adapter.readAiConfig(meta.dir).catch(() => null) @@ -241,15 +257,24 @@ export async function ensureAgentCredentialReady(opts: { const chosen = injectableMap.get(chosenSlug) if (!chosen) throw new AgentCredentialError(agentId) - await adapter.writeAiConfig(meta.dir, chosen.wsCred) - if (chosen.wsCred.model) { - await setCredentialLastModel(chosenSlug, chosen.wsCred.model).catch(() => undefined) + // An explicit Quick Chat credential choice normally re-renders the workspace + // config. When it is the same credential and model, preserve the user's + // model-specific context selection instead of resetting it to the injection + // default. A different key/model starts from the safe default. + const workspaceCred = preserveMatchingWorkspaceContext( + chosen.wsCred, + cfg, + detectedCredentialSlug === chosenSlug, + ) + await adapter.writeAiConfig(meta.dir, workspaceCred) + if (workspaceCred.model) { + await setCredentialLastModel(chosenSlug, workspaceCred.model).catch(() => undefined) } logger?.info('workspace.agent_cred_injected', { id: meta.id, agent: agentId, slug: chosenSlug, - ...(chosen.wsCred.model ? { model: chosen.wsCred.model } : {}), + ...(workspaceCred.model ? { model: workspaceCred.model } : {}), ...(detectedCredentialSlug && detectedCredentialSlug !== chosenSlug ? { replaced: detectedCredentialSlug } : {}), }) diff --git a/src/workspaces/credential-injection.spec.ts b/src/workspaces/credential-injection.spec.ts index 30e5c5d1a..0584ba70e 100644 --- a/src/workspaces/credential-injection.spec.ts +++ b/src/workspaces/credential-injection.spec.ts @@ -78,7 +78,7 @@ describe('credentialToWorkspaceAiCred', () => { expect(cred.wireShape).toBe('openai-chat') expect(cred.authMode).toBeUndefined() expect(cred.wireApi).toBeUndefined() - expect(cred.contextWindow).toBe(1_000_000) + expect(cred.contextWindow).toBe(256_000) expect(cred.apiKey).toBe('k') expect(cred.baseUrl).toBe('https://gw.example.com/v1') }) diff --git a/src/workspaces/credential-injection.ts b/src/workspaces/credential-injection.ts index 445852b08..005447d7e 100644 --- a/src/workspaces/credential-injection.ts +++ b/src/workspaces/credential-injection.ts @@ -34,11 +34,11 @@ export const AGENT_WIRE_PREFERENCE: Record = { pi: ['openai-chat', 'anthropic', 'openai-responses'], } -// Modern coding models are commonly sold as long-context runtimes. Pi defaults -// unknown custom models to 128k and opencode defaults unknown limits to 0, so -// new OpenAlice injections state the assumption explicitly while keeping the -// field overridable from the workspace config UI. -export const DEFAULT_CONTEXT_WINDOW = 1_000_000 +// Unknown custom/local models need an explicit limit: Pi otherwise assumes +// 128k and opencode disables proactive context tracking. A conservative 256k +// default avoids promising a 1M prefill that many local runtimes cannot hold, +// while the per-workspace config remains the model-specific source of truth. +export const DEFAULT_CONTEXT_WINDOW = 256_000 /** * The subset of a credential vault an agent can actually be driven by: those @@ -99,7 +99,7 @@ export function pickAgentWire( export interface CredentialInjectionOverrides { /** Model id to run. Required in practice (a credential has none). */ model?: string - /** Context window to write for custom-model runtimes; defaults to 1M for opencode/Pi. */ + /** Context window to write for custom-model runtimes; defaults to 256K for opencode/Pi. */ contextWindow?: number | null /** Claude only — which header carries the key. Defaults via baseUrl heuristic. */ authMode?: 'x-api-key' | 'bearer' diff --git a/ui/src/components/workspace/WebPiView.spec.tsx b/ui/src/components/workspace/WebPiView.spec.tsx new file mode 100644 index 000000000..527d1b491 --- /dev/null +++ b/ui/src/components/workspace/WebPiView.spec.tsx @@ -0,0 +1,96 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getWebPiSession, type WebPiSnapshot } from './api' +import { isWebPiNearBottom, WebPiView } from './WebPiView' + +vi.mock('./api', async (importOriginal) => ({ + ...(await importOriginal()), + getWebPiSession: vi.fn(), + promptWebPiSession: vi.fn(), + abortWebPiSession: vi.fn(), +})) + +const snapshot: WebPiSnapshot = { + recordId: 'pi-live', + wsId: 'ws-1', + resumeId: 'resume-1', + pid: 42, + startedAt: 1, + phase: 'idle', + state: null, + messages: [], + streamingMessage: null, + error: null, + stderrTail: '', + revision: 3, +} + +beforeEach(() => { + vi.mocked(getWebPiSession).mockResolvedValue(snapshot) + HTMLElement.prototype.scrollTo = vi.fn() +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.useRealTimers() +}) + +describe('WebPi transcript scrolling', () => { + it('distinguishes a reader browsing history from one following the tail', () => { + expect(isWebPiNearBottom({ scrollTop: 100, clientHeight: 300, scrollHeight: 1_000 } as HTMLElement)).toBe(false) + expect(isWebPiNearBottom({ scrollTop: 650, clientHeight: 300, scrollHeight: 1_000 } as HTMLElement)).toBe(true) + }) + + it('does not force history readers back to the bottom and offers an explicit jump', async () => { + const { container } = render( + , + ) + await waitFor(() => expect(getWebPiSession).toHaveBeenCalled()) + + const scroller = container.querySelector('.webpi-messages') as HTMLDivElement + Object.defineProperties(scroller, { + scrollTop: { configurable: true, writable: true, value: 120 }, + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 1_000 }, + }) + fireEvent.scroll(scroller) + + const jump = screen.getByRole('button', { name: 'Jump to latest' }) + fireEvent.click(jump) + + expect(scroller.scrollTo).toHaveBeenLastCalledWith({ top: 1_000, behavior: 'smooth' }) + expect(screen.queryByRole('button', { name: 'Jump to latest' })).toBeNull() + }) + + it('does not force history readers back down when a new snapshot revision arrives', async () => { + vi.useFakeTimers() + let current = snapshot + vi.mocked(getWebPiSession).mockImplementation(async () => current) + const { container } = render( + , + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + const scroller = container.querySelector('.webpi-messages') as HTMLDivElement + Object.defineProperties(scroller, { + scrollTop: { configurable: true, writable: true, value: 120 }, + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 1_000 }, + }) + fireEvent.scroll(scroller) + const scrollCallsBeforeUpdate = vi.mocked(scroller.scrollTo).mock.calls.length + + current = { ...snapshot, revision: snapshot.revision + 1, phase: 'working' } + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500) + }) + + expect(getWebPiSession).toHaveBeenCalledTimes(2) + expect(scroller.scrollTo).toHaveBeenCalledTimes(scrollCallsBeforeUpdate) + expect(screen.getByRole('button', { name: 'Jump to latest' })).toBeTruthy() + }) +}) diff --git a/ui/src/components/workspace/WebPiView.tsx b/ui/src/components/workspace/WebPiView.tsx index d529e95e7..7e6be1ce2 100644 --- a/ui/src/components/workspace/WebPiView.tsx +++ b/ui/src/components/workspace/WebPiView.tsx @@ -18,6 +18,15 @@ import { type WebPiTranscriptItem, } from './webpi-transcript' +const FOLLOW_LATEST_THRESHOLD_PX = 72 + +export function isWebPiNearBottom( + metrics: Pick, + threshold = FOLLOW_LATEST_THRESHOLD_PX, +): boolean { + return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop <= threshold +} + interface Props { readonly wsId: string readonly sessionId: string @@ -32,8 +41,10 @@ export function WebPiView({ wsId, sessionId, label, onSessionLost }: Props): Rea const [snapshot, setSnapshot] = useState(null) const [draft, setDraft] = useState('') const [error, setError] = useState(null) + const [followingLatest, setFollowingLatest] = useState(true) const scrollerRef = useRef(null) const snapshotRef = useRef(null) + const followLatestRef = useRef(true) const acceptSnapshot = useCallback((next: WebPiSnapshot): void => { snapshotRef.current = next @@ -77,15 +88,25 @@ export function WebPiView({ wsId, sessionId, label, onSessionLost }: Props): Rea }, [snapshot]) const transcript = useMemo(() => groupWebPiTranscript(messages), [messages]) + const scrollToLatest = useCallback((behavior: ScrollBehavior = 'smooth'): void => { + const scroller = scrollerRef.current + if (!scroller) return + followLatestRef.current = true + setFollowingLatest(true) + scroller.scrollTo({ top: scroller.scrollHeight, behavior }) + }, []) + useEffect(() => { - scrollerRef.current?.scrollTo({ top: scrollerRef.current.scrollHeight, behavior: 'smooth' }) - }, [snapshot?.revision, transcript.length]) + if (followLatestRef.current) scrollToLatest('auto') + }, [scrollToLatest, snapshot?.revision, transcript.length]) const working = snapshot?.phase === 'working' || snapshot?.phase === 'compacting' || snapshot?.phase === 'retrying' const submit = async (): Promise => { const message = draft.trim() if (!message || working) return + followLatestRef.current = true + setFollowingLatest(true) setDraft('') setError(null) try { @@ -117,7 +138,15 @@ export function WebPiView({ wsId, sessionId, label, onSessionLost }: Props): Rea -
+
{ + const follows = isWebPiNearBottom(event.currentTarget) + followLatestRef.current = follows + setFollowingLatest(follows) + }} + > {messages.length === 0 && !error && (
This Pi conversation is ready in the browser.
)} @@ -138,6 +167,12 @@ export function WebPiView({ wsId, sessionId, label, onSessionLost }: Props): Rea )}
+ {!followingLatest && ( + + )} +