From caaab643ca6848ae9c2cc1e09a9907435fcbb12a Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:35:41 +0800 Subject: [PATCH 01/11] fix(cli): accept kebab-case workspace flags --- src/workspaces/cli/bin/openalice-cli.cjs | 28 +++++++-- src/workspaces/cli/shim.spec.ts | 73 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/workspaces/cli/bin/openalice-cli.cjs b/src/workspaces/cli/bin/openalice-cli.cjs index f4d86a3f5..56b067269 100644 --- a/src/workspaces/cli/bin/openalice-cli.cjs +++ b/src/workspaces/cli/bin/openalice-cli.cjs @@ -77,7 +77,10 @@ async function main() { if (wantsHelp) return printVerbHelp(group, verb, cmd) // Run it. - const args = parseFlags(argv.slice(argv.indexOf(verb) + 1)) + const args = parseFlags( + argv.slice(argv.indexOf(verb) + 1), + (cmd.schema && cmd.schema.properties) || {}, + ) const res = await invoke(base, cmd.tool, args) process.stdout.write(res.endsWith('\n') ? res : res + '\n') } @@ -186,7 +189,7 @@ async function fetchSocketJson(socketPath, path, opts) { // ---- flag parsing --------------------------------------------------------- -function parseFlags(tokens) { +function parseFlags(tokens, properties) { const args = {} const meta = {} const docs = [] @@ -225,7 +228,7 @@ function parseFlags(tokens) { // per-doc fields keep working; a bare path is wrapped into { path }. docs.push(val && typeof val === 'object' ? val : { path: String(val) }) } else { - args[key] = val + args[canonicalFlagName(key, properties)] = val } } if (Object.keys(meta).length) args.metadataFilter = meta @@ -233,6 +236,23 @@ function parseFlags(tokens) { return args } +/** + * JSON-schema properties use JavaScript camelCase, while shell users naturally + * write kebab-case flags. Preserve exact schema keys for compatibility, then + * accept their kebab-case spelling only when it maps to a real property. An + * unknown flag stays unknown so the gateway's strict validation still catches + * typos instead of silently rewriting them. + */ +function canonicalFlagName(name, properties) { + if (Object.prototype.hasOwnProperty.call(properties, name)) return name + const camel = name.replace(/-([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()) + return Object.prototype.hasOwnProperty.call(properties, camel) ? camel : name +} + +function displayFlagName(name) { + return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()) +} + // ---- help rendering ------------------------------------------------------- function printGroups(m) { @@ -271,7 +291,7 @@ function printVerbHelp(group, verb, cmd) { const p = props[n] || {} const type = p.type || (p.enum ? 'enum' : '') const req = required.has(n) ? ' (required)' : '' - out(` --${n}${type ? ' <' + type + '>' : ''}${req} ${firstLine(p.description || '')}`) + out(` --${displayFlagName(n)}${type ? ' <' + type + '>' : ''}${req} ${firstLine(p.description || '')}`) } } diff --git a/src/workspaces/cli/shim.spec.ts b/src/workspaces/cli/shim.spec.ts index 51d383b5e..216c85456 100644 --- a/src/workspaces/cli/shim.spec.ts +++ b/src/workspaces/cli/shim.spec.ts @@ -136,6 +136,79 @@ describe('CLI launchers and payload', () => { } }) + it('shows kebab-case flags and maps them back to exact schema properties', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openalice-cli-shim-flags-')) + const socketPath = process.platform === 'win32' + ? `\\\\.\\pipe\\openalice-cli-shim-flags-${process.pid}-${Date.now()}` + : join(dir, 'tools.sock') + const invocations: Array<{ tool?: string; args?: Record }> = [] + const manifest = { + description: 'test manifest', + groups: { + conversation: { + ask: { + tool: 'conversation_ask', + description: 'Ask a peer conversation', + schema: { + type: 'object', + properties: { + resumeId: { type: 'string', description: 'Conversation identity' }, + timeoutMs: { type: 'number', description: 'Watchdog' }, + prompt: { type: 'string', description: 'Question' }, + }, + required: ['prompt'], + }, + }, + }, + }, + } + const server = createServer((req, res) => { + if (req.method === 'GET') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(manifest)) + return + } + let raw = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { raw += chunk }) + req.on('end', () => { + invocations.push(JSON.parse(raw)) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ content: [{ type: 'text', text: '{"ok":true}' }] })) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + const env = { + ...process.env, + AQ_WS_ID: 'ws1', + OPENALICE_TOOL_SOCKET: socketPath, + OPENALICE_TOOL_URL: '/cli', + } + try { + const help = await runCli('alice-workspace', ['conversation', 'ask', '--help'], env) + expect(help.stdout).toContain('--resume-id ') + expect(help.stdout).toContain('--timeout-ms ') + expect(help.stdout).not.toContain('--resumeId') + + await runCli('alice-workspace', [ + 'conversation', 'ask', + '--resume-id', 'resume-1', + '--timeout-ms', '300000', + '--prompt', 'why?', + ], env) + expect(invocations).toEqual([{ + tool: 'conversation_ask', + args: { resumeId: 'resume-1', timeoutMs: '300000', prompt: 'why?' }, + }]) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + await rm(dir, { recursive: true, force: true }) + } + }) + it('every Windows `.cmd` twin derives its export and selects the managed Node runtime', () => { const canonical = read('alice.cmd') for (const name of EXPORT_BINARIES) { 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 02/11] 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 && ( + + )} +