diff --git a/docs/managed-workspace-runtime.md b/docs/managed-workspace-runtime.md index f64294617..d650e79fb 100644 --- a/docs/managed-workspace-runtime.md +++ b/docs/managed-workspace-runtime.md @@ -202,6 +202,39 @@ 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, and some APIs apply a higher billing tier +above 256K. + +**Settings → AI Provider → New Workspace defaults** owns the user-level +creation preference. Each agent can select the vault credential copied into a +new Workspace; Pi and OpenCode also select the context limit, with 256K as the +visible default. The declaration is stored under +`workspaceCredentialDefaults` in `data/config/ai-provider-manager.json`. +Migration `0023_workspace_default_context_window` makes 256K explicit for +existing Pi/OpenCode declarations. A template's `agentCredentials` entry still +wins for that agent when a template intentionally pins provider state. + +Creation defaults affect only future Workspaces. After creation, the Workspace +config file is the source of truth for its 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/core/config-workspace-cred-defaults.spec.ts b/src/core/config-workspace-cred-defaults.spec.ts index f84e9d5b9..1bd1bde24 100644 --- a/src/core/config-workspace-cred-defaults.spec.ts +++ b/src/core/config-workspace-cred-defaults.spec.ts @@ -40,15 +40,22 @@ describe('workspace credential defaults', () => { it('round-trips a per-agent map and keeps the optional model', async () => { const config = await loadConfigModule() await config.writeWorkspaceCredentialDefaults({ - opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' }, + opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 512_000 }, pi: { credentialSlug: 'anthropic-1' }, }) expect(await config.readWorkspaceCredentialDefaults()).toEqual({ - opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' }, + opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 512_000 }, pi: { credentialSlug: 'anthropic-1' }, }) }) + it('rejects a non-positive context default', async () => { + const config = await loadConfigModule() + await expect(config.writeWorkspaceCredentialDefaults({ + pi: { credentialSlug: 'openai-1', contextWindow: 0 }, + })).rejects.toThrow() + }) + it('drops entries with an empty credentialSlug (the "don\'t seed" choice)', async () => { const config = await loadConfigModule() await config.writeWorkspaceCredentialDefaults({ diff --git a/src/core/config.ts b/src/core/config.ts index 572638f55..141b58333 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -170,6 +170,8 @@ export function credentialWires(cred: Credential): Partial @@ -192,7 +195,7 @@ export const aiProviderSchema = z.object({ credentials: z.record(z.string(), credentialSchema).default({}), /** * Per-agent default credential seeded into EVERY new workspace at create time - * (agentId → {credentialSlug, model?}). The user-level counterpart to a + * (agentId → {credentialSlug, model?, contextWindow?}). The user-level counterpart to a * template's `agentCredentials`: set a default cred per agent once and skip the * per-workspace AI-config modal on each launch. References slugs in * `credentials`; a dangling slug is loud-skipped at injection, never fatal. diff --git a/src/core/workspace-defaults.ts b/src/core/workspace-defaults.ts new file mode 100644 index 000000000..a873ac764 --- /dev/null +++ b/src/core/workspace-defaults.ts @@ -0,0 +1,2 @@ +/** Conservative context limit copied into new Pi/OpenCode Workspace configs. */ +export const DEFAULT_WORKSPACE_CONTEXT_WINDOW = 256_000 diff --git a/src/migrations/0023_workspace_default_context_window.spec.ts b/src/migrations/0023_workspace_default_context_window.spec.ts new file mode 100644 index 000000000..b26689b89 --- /dev/null +++ b/src/migrations/0023_workspace_default_context_window.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + addWorkspaceDefaultContextWindows, + migration, +} from './0023_workspace_default_context_window/index.js' +import type { MigrationContext } from './types.js' + +describe('0023 Workspace default context migration', () => { + it('adds 256K to existing Pi/OpenCode defaults without touching other provider state', () => { + const raw = { + credentials: { 'openai-1': { vendor: 'openai' } }, + workspaceCredentialDefaults: { + opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' }, + pi: { credentialSlug: 'openai-1', contextWindow: 128_000 }, + claude: { credentialSlug: 'anthropic-1' }, + }, + } + + expect(addWorkspaceDefaultContextWindows(raw)).toEqual({ + changed: true, + value: { + ...raw, + workspaceCredentialDefaults: { + opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 256_000 }, + pi: { credentialSlug: 'openai-1', contextWindow: 128_000 }, + claude: { credentialSlug: 'anthropic-1' }, + }, + }, + }) + }) + + it('repairs invalid stored limits and is idempotent after the first write', async () => { + let value: unknown = { + workspaceCredentialDefaults: { + pi: { credentialSlug: 'openai-1', contextWindow: -1 }, + }, + } + const writeJson = vi.fn(async (_filename: string, next: unknown) => { value = next }) + const ctx: MigrationContext = { + async readJson() { return value as T }, + writeJson, + removeJson: vi.fn(async () => undefined), + configDir: () => '/virtual/config', + } + + await migration.up(ctx) + await migration.up(ctx) + + expect(writeJson).toHaveBeenCalledOnce() + expect(value).toEqual({ + workspaceCredentialDefaults: { + pi: { credentialSlug: 'openai-1', contextWindow: 256_000 }, + }, + }) + }) + + it('does not invent defaults when the setting is absent', () => { + expect(addWorkspaceDefaultContextWindows({ credentials: {} })).toEqual({ + changed: false, + value: { credentials: {} }, + }) + }) +}) diff --git a/src/migrations/0023_workspace_default_context_window/index.ts b/src/migrations/0023_workspace_default_context_window/index.ts new file mode 100644 index 000000000..310ddf8f2 --- /dev/null +++ b/src/migrations/0023_workspace_default_context_window/index.ts @@ -0,0 +1,60 @@ +import { DEFAULT_WORKSPACE_CONTEXT_WINDOW } from '@/core/workspace-defaults.js' +import type { Migration } from '../types.js' + +type JsonRecord = Record + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export interface WorkspaceDefaultContextMigrationResult { + readonly changed: boolean + readonly value: unknown +} + +/** Add an explicit safe context preference to existing Pi/OpenCode defaults. */ +export function addWorkspaceDefaultContextWindows( + raw: unknown, +): WorkspaceDefaultContextMigrationResult { + if (!isRecord(raw) || !isRecord(raw.workspaceCredentialDefaults)) { + return { changed: false, value: raw } + } + + const nextDefaults: JsonRecord = { ...raw.workspaceCredentialDefaults } + let changed = false + for (const agentId of ['opencode', 'pi']) { + const current = nextDefaults[agentId] + if (!isRecord(current) || typeof current.credentialSlug !== 'string' || !current.credentialSlug) { + continue + } + if ( + typeof current.contextWindow === 'number' && + Number.isInteger(current.contextWindow) && + current.contextWindow > 0 + ) { + continue + } + nextDefaults[agentId] = { + ...current, + contextWindow: DEFAULT_WORKSPACE_CONTEXT_WINDOW, + } + changed = true + } + + return changed + ? { changed: true, value: { ...raw, workspaceCredentialDefaults: nextDefaults } } + : { changed: false, value: raw } +} + +export const migration: Migration = { + id: '0023_workspace_default_context_window', + appVersion: '0.81.0-beta', + introducedAt: '2026-07-15', + affects: ['ai-provider-manager.json'], + summary: 'Make the 256K Pi/OpenCode context limit explicit in existing new-Workspace credential defaults.', + up: async (ctx) => { + const raw = await ctx.readJson('ai-provider-manager.json') + const result = addWorkspaceDefaultContextWindows(raw) + if (result.changed) await ctx.writeJson('ai-provider-manager.json', result.value) + }, +} diff --git a/src/migrations/INDEX.md b/src/migrations/INDEX.md index a146baf3c..b75723c58 100644 --- a/src/migrations/INDEX.md +++ b/src/migrations/INDEX.md @@ -22,3 +22,4 @@ Each row corresponds to one migration in `src/migrations/`. The runner applies p | `0020_headless_issue_trigger` | 0.80.0-beta | 2026-07-12 | workspaces/state/headless-tasks.json | Store the composite Issue trigger separately from a run execution Workspace. | | `0021_workspace_departure_catalog` | 0.80.0-beta | 2026-07-12 | workspaces/workspaces.json, workspaces/workspaces/*, workspaces/departed-workspaces/*, workspaces/state/workspace-catalog.json | Move unregistered Workspace directories into a durable departed catalog without deleting them. | | `0022_connector_service_config` | 0.81.0-beta | 2026-07-13 | connectors.json, connector-service.json, ports.json | Move external notifications into sealed Connector Service config and retire legacy Web/MCP-Ask connector meanings. | +| `0023_workspace_default_context_window` | 0.81.0-beta | 2026-07-15 | ai-provider-manager.json | Make the 256K Pi/OpenCode context limit explicit in existing new-Workspace credential defaults. | diff --git a/src/migrations/registry.ts b/src/migrations/registry.ts index 210399a39..8d22c2158 100644 --- a/src/migrations/registry.ts +++ b/src/migrations/registry.ts @@ -14,7 +14,7 @@ * deletion + Workspace pivot turned the pre-0.40 data shapes over completely, so * pre-0.40 installs rebuild `data/` rather than migrate. The framework stays for * future upgrades. Numbering continues FORWARD from the highest id ever shipped - * (next: 0023) — never reuse a retired id, since existing installs' journals + * (next: 0024) — never reuse a retired id, since existing installs' journals * recorded the old ones. */ @@ -34,6 +34,7 @@ import { migration as migration_0019_issue_session_signatures } from './0019_iss import { migration as migration_0020_headless_issue_trigger } from './0020_headless_issue_trigger/index.js' import { migration as migration_0021_workspace_departure_catalog } from './0021_workspace_departure_catalog/index.js' import { migration as migration_0022_connector_service_config } from './0022_connector_service_config/index.js' +import { migration as migration_0023_workspace_default_context_window } from './0023_workspace_default_context_window/index.js' export const REGISTRY: Migration[] = [ migration_0008_disable_targetless_cron_jobs, @@ -51,4 +52,5 @@ export const REGISTRY: Migration[] = [ migration_0020_headless_issue_trigger, migration_0021_workspace_departure_catalog, migration_0022_connector_service_config, + migration_0023_workspace_default_context_window, ] diff --git a/src/webui/routes/config-workspace-defaults.spec.ts b/src/webui/routes/config-workspace-defaults.spec.ts index c9edceafd..e8a9bbd88 100644 --- a/src/webui/routes/config-workspace-defaults.spec.ts +++ b/src/webui/routes/config-workspace-defaults.spec.ts @@ -94,7 +94,9 @@ describe('GET /workspace-credential-defaults', () => { const { status, body } = await req(routes, 'GET', '/workspace-credential-defaults') expect(status).toBe(200) - expect(body!.defaults).toEqual({ opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' } }) + expect(body!.defaults).toEqual({ + opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 256_000 }, + }) const compat = body!.compatibleByAgent as Record // claude speaks anthropic only. @@ -219,18 +221,18 @@ describe('GET/PUT /issue-default-agent', () => { }) describe('PUT /workspace-credential-defaults', () => { - it('replaces the map, keeps optional model, persists via the writer', async () => { + it('replaces the map, keeps optional model/context, and defaults missing context to 256K', async () => { const routes = createConfigRoutes() const { status, body } = await req(routes, 'PUT', '/workspace-credential-defaults', { defaults: { opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' }, - pi: { credentialSlug: 'anthropic-1' }, + pi: { credentialSlug: 'anthropic-1', contextWindow: 512_000 }, }, }) expect(status).toBe(200) expect(body!.defaults).toEqual({ - opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' }, - pi: { credentialSlug: 'anthropic-1' }, + opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 256_000 }, + pi: { credentialSlug: 'anthropic-1', contextWindow: 512_000 }, }) expect(defaultsStore).toEqual(body!.defaults) }) @@ -240,7 +242,9 @@ describe('PUT /workspace-credential-defaults', () => { const { body } = await req(routes, 'PUT', '/workspace-credential-defaults', { defaults: { opencode: { credentialSlug: 'openai-1' }, pi: { credentialSlug: '' } }, }) - expect(body!.defaults).toEqual({ opencode: { credentialSlug: 'openai-1' } }) + expect(body!.defaults).toEqual({ + opencode: { credentialSlug: 'openai-1', contextWindow: 256_000 }, + }) }) it('ignores unknown agent keys (only the four defaultable agents pass through)', async () => { @@ -251,6 +255,14 @@ describe('PUT /workspace-credential-defaults', () => { expect(body!.defaults).toEqual({}) }) + it('ignores contextWindow for runtimes that do not expose that Workspace setting', async () => { + const routes = createConfigRoutes() + const { body } = await req(routes, 'PUT', '/workspace-credential-defaults', { + defaults: { claude: { credentialSlug: 'anthropic-1', contextWindow: 1_000_000 } }, + }) + expect(body!.defaults).toEqual({ claude: { credentialSlug: 'anthropic-1' } }) + }) + it('clears all defaults on an empty body', async () => { const routes = createConfigRoutes() defaultsStore = { opencode: { credentialSlug: 'openai-1' } } diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index 6b571a300..00f7783ad 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -10,6 +10,7 @@ import { type ConfigSection, type Credential, type CredentialWireShape, type WorkspaceCredentialDefault, } from '../../core/config.js' +import { DEFAULT_WORKSPACE_CONTEXT_WINDOW } from '../../core/workspace-defaults.js' import { compatibleCredentials } from '../../workspaces/credential-injection.js' /** Validate a `{ [wireShape]: baseUrl }` body into a typed wires map. */ @@ -219,6 +220,27 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { // user the per-workspace AI-config modal. References the vault above. const DEFAULTABLE_AGENTS = ['claude', 'codex', 'opencode', 'pi'] as const + const CONTEXT_AGENTS = new Set(['opencode', 'pi']) + + const normalizeWorkspaceDefaults = ( + incoming: Record, + ): Record => { + const next: Record = {} + for (const agent of DEFAULTABLE_AGENTS) { + const def = incoming[agent] + if (!def || typeof def.credentialSlug !== 'string' || !def.credentialSlug) continue + const contextWindow = typeof def.contextWindow === 'number' && + Number.isInteger(def.contextWindow) && def.contextWindow > 0 + ? def.contextWindow + : DEFAULT_WORKSPACE_CONTEXT_WINDOW + next[agent] = { + credentialSlug: def.credentialSlug, + ...(typeof def.model === 'string' && def.model ? { model: def.model } : {}), + ...(CONTEXT_AGENTS.has(agent) ? { contextWindow } : {}), + } + } + return next + } /** * GET /workspace-credential-defaults — the current per-agent defaults plus, @@ -235,7 +257,7 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { for (const agent of DEFAULTABLE_AGENTS) { compatibleByAgent[agent] = compatibleCredentials(creds, agent).map(([slug]) => slug) } - return c.json({ defaults, compatibleByAgent }) + return c.json({ defaults: normalizeWorkspaceDefaults(defaults), compatibleByAgent }) } catch (err) { return c.json({ error: String(err) }, 500) } @@ -243,23 +265,13 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { /** * PUT /workspace-credential-defaults — replace the whole per-agent map. Body: - * `{ defaults: { [agentId]: { credentialSlug, model? } } }`. An empty/absent + * `{ defaults: { [agentId]: { credentialSlug, model?, contextWindow? } } }`. An empty/absent * `credentialSlug` for an agent clears its default (handled in the writer). */ app.put('/workspace-credential-defaults', async (c) => { try { const body = await c.req.json<{ defaults?: Record }>() - const incoming = body.defaults ?? {} - const next: Record = {} - for (const agent of DEFAULTABLE_AGENTS) { - const def = incoming[agent] - if (def && typeof def.credentialSlug === 'string' && def.credentialSlug) { - next[agent] = { - credentialSlug: def.credentialSlug, - ...(typeof def.model === 'string' && def.model ? { model: def.model } : {}), - } - } - } + const next = normalizeWorkspaceDefaults(body.defaults ?? {}) await writeWorkspaceCredentialDefaults(next) return c.json({ defaults: next }) } catch (err) { 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..20d8ab587 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') }) @@ -86,8 +86,8 @@ describe('credentialToWorkspaceAiCred', () => { }) it('lets opencode/pi override the default context window', () => { - const cred = credentialToWorkspaceAiCred(chatOnlyGateway, 'pi', { model: 'some-model', contextWindow: 256_000 })! - expect(cred.contextWindow).toBe(256_000) + const cred = credentialToWorkspaceAiCred(chatOnlyGateway, 'pi', { model: 'some-model', contextWindow: 128_000 })! + expect(cred.contextWindow).toBe(128_000) }) }) @@ -152,6 +152,56 @@ describe('injectWorkspaceCredentials', () => { expect(codexCall.cred).toMatchObject({ apiKey: 'sk-oa', model: 'gpt-5.5' }) }) + it('copies a new-Workspace context preference into Pi/OpenCode config', async () => { + const calls: WriteCall[] = [] + const reg = new AdapterRegistry() + reg.register(stubAdapter('pi', calls)) + const { logger } = fakeLogger() + + await injectWorkspaceCredentials({ + dir: '/ws', + agents: ['pi'], + agentCredentials: { + pi: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 512_000 }, + }, + adapterRegistry: reg, + credentials, + logger, + }) + + expect(calls).toHaveLength(1) + expect(calls[0]?.cred).toMatchObject({ contextWindow: 512_000, model: 'gpt-5.5' }) + }) + + it('resolves the credential model when a new-Workspace default only pins credential + context', async () => { + const calls: WriteCall[] = [] + const reg = new AdapterRegistry() + reg.register(stubAdapter('pi', calls)) + reg.register(stubAdapter('opencode', calls)) + const { logger } = fakeLogger() + + await injectWorkspaceCredentials({ + dir: '/ws', + agents: ['pi', 'opencode'], + agentCredentials: { + pi: { credentialSlug: 'openai-1', contextWindow: 256_000 }, + opencode: { credentialSlug: 'anthropic-1', contextWindow: 128_000 }, + }, + adapterRegistry: reg, + credentials, + logger, + }) + + expect(calls.find((call) => call.id === 'pi')?.cred).toMatchObject({ + model: 'gpt-5.5', + contextWindow: 256_000, + }) + expect(calls.find((call) => call.id === 'opencode')?.cred).toMatchObject({ + model: 'claude-opus-4-8', + contextWindow: 128_000, + }) + }) + it('skips (loud warn) an agent declared but not enabled on the workspace', async () => { const calls: WriteCall[] = [] const reg = new AdapterRegistry() diff --git a/src/workspaces/credential-injection.ts b/src/workspaces/credential-injection.ts index 445852b08..cfd9d4d1f 100644 --- a/src/workspaces/credential-injection.ts +++ b/src/workspaces/credential-injection.ts @@ -1,6 +1,8 @@ /** * Bridge from Alice's central credential store to a workspace's per-CLI AI - * config. + * config. When a declaration does not pin a model, creation resolves the + * credential's remembered model and then its vendor flagship so Pi/OpenCode + * can materialize their context limit instead of writing a provider-only file. * * The central store (`aiProviderSchema.credentials` in `core/config.ts`) holds * the vendor-neutral secret: `{ vendor, authType, apiKey?, baseUrl? }`. Each CLI @@ -16,6 +18,7 @@ import { resolveAnthropicAuthMode } from '@/core/credential-inference.js' import { credentialWires, type Credential, type CredentialWireShape } from '@/core/config.js' +import { DEFAULT_WORKSPACE_CONTEXT_WINDOW } from '@/core/workspace-defaults.js' import { DEFAULT_MODEL_BY_VENDOR } from '@/ai-providers/preset-catalog.js' import type { AdapterRegistry, WorkspaceAiCred } from './cli-adapter.js' import type { Logger } from './logger.js' @@ -34,11 +37,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 = DEFAULT_WORKSPACE_CONTEXT_WINDOW /** * The subset of a credential vault an agent can actually be driven by: those @@ -99,7 +102,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' @@ -186,8 +189,10 @@ export async function injectWorkspaceCredentials(opts: { }) continue } + const resolvedModel = decl.model ?? resolveInjectionModel(credential) const wsCred = credentialToWorkspaceAiCred(credential, agentId, { - ...(decl.model !== undefined ? { model: decl.model } : {}), + ...(resolvedModel ? { model: resolvedModel } : {}), + ...(decl.contextWindow !== undefined ? { contextWindow: decl.contextWindow } : {}), ...(decl.authMode !== undefined ? { authMode: decl.authMode } : {}), ...(decl.wireApi !== undefined ? { wireApi: decl.wireApi } : {}), }) @@ -202,7 +207,7 @@ export async function injectWorkspaceCredentials(opts: { } await adapter.writeAiConfig(dir, wsCred) logger.info('workspace.cred_injected', { - agentId, credentialSlug: decl.credentialSlug, ...(decl.model ? { model: decl.model } : {}), + agentId, credentialSlug: decl.credentialSlug, ...(resolvedModel ? { model: resolvedModel } : {}), }) } } diff --git a/src/workspaces/template-registry.ts b/src/workspaces/template-registry.ts index 6d886a6fa..c0ffe3fec 100644 --- a/src/workspaces/template-registry.ts +++ b/src/workspaces/template-registry.ts @@ -14,6 +14,8 @@ import type { Logger } from './logger.js'; export interface AgentCredentialDecl { readonly credentialSlug: string; readonly model?: string; + /** Pi/OpenCode only; ignored by runtimes without a Workspace context knob. */ + readonly contextWindow?: number; /** Claude only. */ readonly authMode?: 'x-api-key' | 'bearer'; /** Codex only. */ @@ -323,6 +325,7 @@ async function readTemplateMeta(path: string): Promise { /** * Parse the optional `agentCredentials` map from a template.json. Shape: * { "": { "credentialSlug": "openai-1", "model": "gpt-5.5", + * "contextWindow"?: 256000, * "authMode"?: "x-api-key"|"bearer", "wireApi"?: "chat"|"responses" } } * Entries missing a string `credentialSlug` are dropped. Returns undefined when * nothing valid is present (so the field stays absent on the meta). @@ -336,6 +339,13 @@ function parseAgentCredentials(raw: unknown): Record 0 + ) { + (decl as { contextWindow?: number }).contextWindow = v['contextWindow']; + } if (v['authMode'] === 'x-api-key' || v['authMode'] === 'bearer') { (decl as { authMode?: 'x-api-key' | 'bearer' }).authMode = v['authMode']; } diff --git a/ui/src/api/config.ts b/ui/src/api/config.ts index c68f33deb..6b9a4788d 100644 --- a/ui/src/api/config.ts +++ b/ui/src/api/config.ts @@ -96,10 +96,12 @@ export const configApi = { } -/** A per-agent default credential seeded into new workspaces. */ +/** A per-agent provider state seeded into new workspaces. */ export interface WorkspaceCredentialDefault { credentialSlug: string model?: string + /** Pi/OpenCode only. */ + contextWindow?: number } /** GET /workspace-credential-defaults — current defaults + per-agent picker options. */ 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 && ( + + )} +