diff --git a/docs/model-semantics-and-runtime-injection.md b/docs/model-semantics-and-runtime-injection.md index 1d38bdc0d..4c92d3107 100644 --- a/docs/model-semantics-and-runtime-injection.md +++ b/docs/model-semantics-and-runtime-injection.md @@ -133,6 +133,20 @@ of an explicitly configured Workspace provider. Vault secrets are resolved just in time and enter only the child environment. Workspace fingerprints make replacement visible instead of silently resuming through a different key. +Credential, model, and effort are independent optional launch dimensions. A +native credential binding means OpenAlice injects no managed key or endpoint; +the Agent runtime owns authentication and provider discovery through its normal +login/config chain. That native binding may still carry a process-level model +or effort override. A native binding with neither override is also valid and +must still traverse the adapter projection seam, even when the resulting +projection is empty. + +Legacy resume identities that predate this binding contract are upgraded to an +explicit native binding on their next activation. They must not inspect and +adopt a provider that was added to the Workspace after the Session was created. +Fresh Sessions may still resolve an existing Workspace-local provider as their +creation default and persist that choice before the first process starts. + Every Agent adapter must implement `sessionRuntime.project(...)`. Registration rejects an Agent adapter without that contract; utility adapters such as Shell explicitly opt out. The adapter maps the same resolved binding to its native @@ -218,20 +232,21 @@ replace the curated registry. The UI must disclose configuration ownership instead of presenting every resolved launch value as if it were already on disk. A launch surface -distinguishes a Workspace-local binding from a credential that will be written -only when the next session starts. Creation defaults are also explicitly -creation-time policy: changing one never rewrites an existing Workspace. +distinguishes a Workspace-local default from a credential that will be bound +only to the next Session. Selecting the latter must not rewrite the Workspace. +Creation defaults are also explicitly creation-time policy: changing one never +rewrites an existing Workspace. Stable Workspace ownership stays implicit so provenance does not displace the -effective model, reasoning, and context values. When Send will write or replace -runtime configuration, that pending side effect is disclosed on its own line; -successful explicit saves use transient confirmation instead of a permanent -success state. +effective model, reasoning, and context values. When Send will apply a +Session-only provider/model override, that ownership is disclosed on its own +line; successful explicit Workspace saves use transient confirmation instead +of a permanent success state. This disclosure applies to all four supported Agent runtimes. Claude Code and Codex use their native global login and global runtime configuration by default. Merely storing a compatible credential in Alice never selects or injects it; -only an explicit Workspace binding or explicit new-Workspace creation default -overrides the native fallback. When a Workspace-local override exists its real -model must be shown instead of a generic “runtime managed” label. Their native +only an explicit Session selection, Workspace binding, or new-Workspace +creation default overrides the native fallback. When a Workspace-local override +exists its real model must be shown instead of a generic “runtime managed” label. Their native project files do not declare a context limit, so the UI omits that field rather than borrowing the Pi/opencode injection default. diff --git a/plans/session-runtime-bindings.md b/plans/session-runtime-bindings.md index 75f955d4b..256f77d7c 100644 --- a/plans/session-runtime-bindings.md +++ b/plans/session-runtime-bindings.md @@ -33,8 +33,10 @@ Agent runtime to implement one Session contract. records, logs, command arguments, or fixtures. 5. Native runtime login is a first-class explicit source. A missing override does not cause OpenAlice to pick or inject an arbitrary vault credential. -6. Legacy identities without a binding retain compatibility resolution once; - all newly-created Sessions persist an explicit binding before spawn. +6. Legacy identities without a binding upgrade to explicit native-runtime + ownership on their next activation; they never adopt mutable Workspace + provider state that appeared after the Session was created. All newly-created + Sessions persist an explicit binding before spawn. 7. Exact Session resumes replay their stored binding and reject conflicting runtime/model/effort/credential input instead of silently changing it. @@ -64,6 +66,8 @@ Agent runtime to implement one Session contract. - [x] Preserve Issue declarations as Session-creation preferences and expose safe effective binding metadata on Session/run projections. - [x] Update UI/demo contracts where launch selection already exists. +- [x] Keep credential, model, and effort independently optional; make both + Quick Chat and Workspace Manager submit the visible selection atomically. ### 4. Verification and delivery @@ -80,7 +84,10 @@ Delivered one mandatory adapter contract across Claude Code, Codex, OpenCode, and Pi; persisted versioned bindings on `resumeId`; removed the headless-only override seam; and verified the same binding in browser/dev and an isolated packaged Electron scheduled-Pi run. Utility Shell Sessions remain explicitly -outside the Agent runtime binding contract. +outside the Agent runtime binding contract. A follow-up hardening pass made +legacy binding absence mean native runtime ownership, fixed login-backed +credential/model selection as one Session launch, and carried the same optional +model/effort values through Workspace Manager. ## Completion Criteria diff --git a/src/webui/routes/workspaces-quickchat.spec.ts b/src/webui/routes/workspaces-quickchat.spec.ts index a13ca596f..fd2762e99 100644 --- a/src/webui/routes/workspaces-quickchat.spec.ts +++ b/src/webui/routes/workspaces-quickchat.spec.ts @@ -189,6 +189,7 @@ function build(opts: { app, opencode, spawn, + resumeRecords, creator, rememberRecentChatWorkspace, rememberAutoQuantDefaultWorkspace, @@ -546,6 +547,39 @@ describe('POST /quick-chat — native auth and explicit credential overrides', ( expect(runtime.ai.apiKey).toBe('sk-second'); }); + it('upgrades a legacy resumed Session to native ownership without reading current Workspace credentials', async () => { + vi.mocked(readCredentials).mockResolvedValue({}); + const { app, opencode, resumeRecords, spawn } = build({ + opencodeConfig: { + apiKey: 'workspace-key-added-after-session-creation', + model: 'workspace-model-added-later', + wireShape: 'openai-chat', + }, + }); + resumeRecords.set('resume-legacy', { + resumeId: 'resume-legacy', + wsId: 'ws-1', + agent: 'opencode', + agentSessionId: 'native-session-1', + }); + + const result = await spawnSession(app, { + agent: 'opencode', + resumeId: 'resume-legacy', + }); + + expect(result.status).toBe(201); + expect(opencode.readAiConfig).not.toHaveBeenCalled(); + expect((spawn.mock.calls[0] as any[])[1].sessionRuntime).toEqual({ + binding: { version: 1, credential: { source: 'native' } }, + ai: null, + }); + expect(resumeRecords.get('resume-legacy').runtimeBinding).toEqual({ + version: 1, + credential: { source: 'native' }, + }); + }); + it('explicit credential pick overrides a globally-ready opencode config', async () => { vi.mocked(readCredentials).mockResolvedValue({ 'openai-2': { ...openaiKey, apiKey: 'sk-second', lastModel: 'gpt-5.5-mini' }, diff --git a/src/webui/routes/workspaces.spec.ts b/src/webui/routes/workspaces.spec.ts index cb42fd4db..d1131a7e8 100644 --- a/src/webui/routes/workspaces.spec.ts +++ b/src/webui/routes/workspaces.spec.ts @@ -1311,13 +1311,28 @@ describe('Workspace manager surface routes', () => { const result = await post(app, '/manager/quick-start', { prompt: 'Map ownership.', agent: 'codex', + model: 'gpt-5.6-terra', + reasoningEffort: 'high', }); expect(result.status).toBe(201); expect(result.body).toMatchObject({ session: { wsId: 'workspace-manager', agent: 'codex', surface: 'terminal' }, snapshot: null, }); - expect(spawnedContext).toMatchObject({ agentId: 'codex' }); + expect(spawnedContext).toMatchObject({ + agentId: 'codex', + sessionRuntime: { + binding: { + credential: { source: 'native' }, + model: 'gpt-5.6-terra', + reasoningEffort: 'high', + }, + ai: { + model: 'gpt-5.6-terra', + reasoningEffort: 'high', + }, + }, + }); expect(result.body).toMatchObject({ session: { title: 'Map ownership.' } }); expect(spawnedContext.initialPrompt).toContain('OpenAlice Workspace Manager'); expect(spawnedContext.initialPrompt).toContain('User request:\nMap ownership.'); diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index a2c647d15..e426c6235 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -55,6 +55,7 @@ import { type ModelReasoningMode, } from '../../ai-providers/model-semantics.js'; import { + createNativeSessionRuntimeBinding, createSessionRuntimeBinding, resolveSessionRuntimeBinding, SessionRuntimeBindingError, @@ -322,12 +323,14 @@ export function createWorkspaceRoutes( let sessionRuntime: ResolvedSessionRuntimeBinding | undefined; if (isAgentRuntime(adapter)) { try { - sessionRuntime = requestedIdentity?.runtimeBinding - ? await resolveSessionRuntimeBinding({ - adapter, - cwd: meta.dir, - binding: requestedIdentity.runtimeBinding, - }) + sessionRuntime = requestedIdentity + ? requestedIdentity.runtimeBinding + ? await resolveSessionRuntimeBinding({ + adapter, + cwd: meta.dir, + binding: requestedIdentity.runtimeBinding, + }) + : createNativeSessionRuntimeBinding({ adapter }) : await createSessionRuntimeBinding({ adapter, cwd: meta.dir, @@ -589,6 +592,8 @@ export function createWorkspaceRoutes( let prompt: string; let agentId: string | undefined; let credentialSlug: string | undefined; + let model: string | undefined; + let reasoningEffort: ModelReasoningEffort | undefined; try { const body = await safeJson(c); const fields = body && typeof body === 'object' ? body as Record : {}; @@ -602,6 +607,10 @@ export function createWorkspaceRoutes( if (typeof fields['credentialSlug'] === 'string' && fields['credentialSlug'].length > 0) { credentialSlug = fields['credentialSlug']; } + const rawModel = fields['model']; + if (typeof rawModel === 'string' && rawModel.trim().length > 0) model = rawModel.trim(); + const rawEffort = fields['reasoningEffort']; + if (isModelReasoningEffort(rawEffort)) reasoningEffort = rawEffort; } catch (error) { return c.json({ error: 'bad_request', message: (error as Error).message }, 400); } @@ -624,6 +633,8 @@ export function createWorkspaceRoutes( const spawned = await spawnInteractiveSession(meta, { agentId: resolvedAgentId, ...(credentialSlug ? { credentialSlug } : {}), + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), ...(resolvedAgentId === 'pi' ? {} : { initialPrompt: managerTerminalPrompt(prompt) }), title: prompt, }); @@ -1437,8 +1448,8 @@ export function createWorkspaceRoutes( prompt = seed.prompt; const rawAgent = fields['agent']; if (typeof rawAgent === 'string' && rawAgent.length > 0) agentId = rawAgent; - // Optional: which vault credential to seed a loginless runtime with. Only - // consulted for opencode/pi; claude/codex ignore it (own login). + // Optional Session-only vault override. Every Agent adapter owns how it + // projects the selected credential; omission preserves native auth. const rawSlug = fields['credentialSlug']; if (typeof rawSlug === 'string' && rawSlug.length > 0) credentialSlug = rawSlug; const rawModel = fields['model']; @@ -1646,7 +1657,7 @@ export function createWorkspaceRoutes( cwd: meta.dir, binding: identity.runtimeBinding, }) - : await createSessionRuntimeBinding({ adapter, cwd: meta.dir }); + : createNativeSessionRuntimeBinding({ adapter }); if (!identity?.runtimeBinding) { await svc.resumeRegistry.ensure({ resumeId: record.resumeId, diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index b26b6849a..064d659c4 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -38,6 +38,7 @@ import { import { loadConfig, type ServerConfig } from './config.js'; import { ensureAgentCredentialReady } from './agent-credential-readiness.js'; import { + createNativeSessionRuntimeBinding, createSessionRuntimeBinding, resolveSessionRuntimeBinding, type SessionRuntimeSelection, @@ -1495,7 +1496,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions } sessionRuntime = identity.runtimeBinding ? await resolveSessionRuntimeBinding({ adapter, cwd: ws.dir, binding: identity.runtimeBinding }) - : await createSessionRuntimeBinding({ adapter, cwd: ws.dir }); + : createNativeSessionRuntimeBinding({ adapter }); } else { sessionRuntime = await createSessionRuntimeBinding({ adapter, @@ -2194,7 +2195,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions cwd: meta.dir, binding: identity.runtimeBinding, }) - : await createSessionRuntimeBinding({ adapter, cwd: meta.dir }); + : createNativeSessionRuntimeBinding({ adapter }); if (!identity?.runtimeBinding) { await resumeRegistry.ensure({ resumeId: record.resumeId, diff --git a/src/workspaces/session-runtime-binding.spec.ts b/src/workspaces/session-runtime-binding.spec.ts index dc7f302f3..1aca3820d 100644 --- a/src/workspaces/session-runtime-binding.spec.ts +++ b/src/workspaces/session-runtime-binding.spec.ts @@ -13,6 +13,7 @@ import { codexAdapter } from './adapters/codex.js' import { opencodeAdapter } from './adapters/opencode.js' import { piAdapter } from './adapters/pi.js' import { + createNativeSessionRuntimeBinding, createSessionRuntimeBinding, resolveSessionRuntimeBinding, SessionRuntimeBindingError, @@ -47,6 +48,32 @@ function fakeAdapter(readAiConfig: () => Promise): CliAd } describe('durable Session runtime binding', () => { + it('represents native credentials as an explicit optional binding with independent model and effort', () => { + const readAiConfig = vi.fn(async (): Promise => ({ + apiKey: 'workspace-secret-that-must-not-be-read', + model: 'workspace-model', + wireShape: 'openai-responses', + })) + const adapter = fakeAdapter(readAiConfig) + + expect(createNativeSessionRuntimeBinding({ + adapter, + selection: { model: 'native-model-override', reasoningEffort: 'low' }, + })).toEqual({ + binding: { + version: 1, + credential: { source: 'native' }, + model: 'native-model-override', + reasoningEffort: 'low', + }, + ai: { + model: 'native-model-override', + reasoningEffort: 'low', + }, + }) + expect(readAiConfig).not.toHaveBeenCalled() + }) + it('persists a vault reference and resolved model without persisting its key', async () => { const resolved = await createSessionRuntimeBinding({ adapter: codexAdapter, @@ -172,6 +199,27 @@ describe('built-in Agent Session runtime projection', () => { }, ) + it.each([claudeAdapter, codexAdapter, opencodeAdapter, piAdapter])( + '$id accepts a credentialless native binding and still projects model/effort', + (adapter) => { + const native = createNativeSessionRuntimeBinding({ + adapter, + selection: { model: 'native-model-override', reasoningEffort: 'medium' }, + }) + const projected = adapter.sessionRuntime!.project(ctx, native) + const serializedEnv = Object.values(projected.env).join(' ') + const serializedArgs = [ + ...projected.interactiveArgs, + ...projected.headlessArgs, + ...(projected.webArgs ?? []), + ].join(' ') + + expect(serializedArgs).toContain('native-model-override') + expect(serializedEnv).not.toContain('sk-') + expect(native.ai).not.toHaveProperty('apiKey') + }, + ) + it('projects the native model and effort flags on every launch surface', () => { expect(claudeAdapter.sessionRuntime!.project(ctx, runtime).interactiveArgs) .toEqual(['--model', 'session-model', '--effort', 'high']) diff --git a/src/workspaces/session-runtime-binding.ts b/src/workspaces/session-runtime-binding.ts index 3321c0d4b..ebe468534 100644 --- a/src/workspaces/session-runtime-binding.ts +++ b/src/workspaces/session-runtime-binding.ts @@ -38,6 +38,35 @@ export interface SessionRuntimeSelection { readonly reasoningEffort?: SessionRuntimeBinding['reasoningEffort'] } +/** + * Freeze an explicit native-runtime binding without consulting Workspace files. + * + * This is intentionally separate from `createSessionRuntimeBinding()`: fresh + * Sessions may adopt a Workspace-local creation default, while an existing + * legacy Session whose identity predates runtime bindings must not silently + * adopt whatever provider happens to be in that Workspace today. Native auth, + * model, and provider discovery remain owned by the child runtime; OpenAlice + * may still project an independently persisted model or effort override. + */ +export function createNativeSessionRuntimeBinding(input: { + readonly adapter: CliAdapter + readonly selection?: Omit +}): ResolvedSessionRuntimeBinding { + assertedAgentContract(input.adapter) + const selection = input.selection ?? {} + const binding: SessionRuntimeBinding = { + version: 1, + credential: { source: 'native' }, + ...modelFields(selection.model, selection.reasoningEffort), + } + return { + binding, + ai: binding.model || binding.reasoningEffort + ? { model: binding.model ?? null, reasoningEffort: binding.reasoningEffort ?? null } + : null, + } +} + function providerFingerprint(ai: WorkspaceAiCred): string { return createHash('sha256').update(JSON.stringify({ baseUrl: ai.baseUrl ?? null, @@ -134,17 +163,7 @@ export async function createSessionRuntimeBinding(input: { const workspace = await input.adapter.readAiConfig?.(input.cwd).catch(() => null) ?? null if (!workspace) { - const binding: SessionRuntimeBinding = { - version: 1, - credential: { source: 'native' }, - ...modelFields(selection.model, selection.reasoningEffort), - } - return { - binding, - ai: binding.model || binding.reasoningEffort - ? { model: binding.model ?? null, reasoningEffort: binding.reasoningEffort ?? null } - : null, - } + return createNativeSessionRuntimeBinding({ adapter: input.adapter, selection }) } const selectedModel = selection.model ?? workspace.model ?? undefined diff --git a/ui/src/components/workspace/api.ts b/ui/src/components/workspace/api.ts index 37386d306..07067032d 100644 --- a/ui/src/components/workspace/api.ts +++ b/ui/src/components/workspace/api.ts @@ -881,11 +881,17 @@ export async function quickStartWorkspaceManager( prompt: string, agent: string, credentialSlug?: string, + model?: string | null, + reasoningEffort?: ModelReasoningEffort, ): Promise { + const request: Record = { prompt, agent }; + if (credentialSlug !== undefined) request['credentialSlug'] = credentialSlug; + if (model) request['model'] = model; + if (reasoningEffort) request['reasoningEffort'] = reasoningEffort; const res = await fetch('/api/workspaces/manager/quick-start', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ prompt, agent, ...(credentialSlug ? { credentialSlug } : {}) }), + body: JSON.stringify(request), }) const body = (await res.json().catch(() => null)) as (ManagerQuickStartResult & { message?: string; error?: string }) | null if (!res.ok || !body?.manager || !body.session || !('snapshot' in body)) { @@ -907,8 +913,9 @@ export class QuickChatError extends Error { * Quick-chat launch — the "type a message → you're in" front door. One POST * reuses-or-creates the chat workspace and spawns a fresh session seeded with * `prompt`; the returned `session.sessionId` is what the caller attaches to. - * `credentialSlug` seeds a loginless runtime (opencode/pi) — ignored for - * claude/codex, which carry their own CLI login. + * Credential, model, and effort are independent optional Session overrides. + * Omitting a credential leaves authentication/provider discovery to the + * runtime; choosing one never rewrites the Workspace merely to launch. */ export async function quickChat( prompt: string, diff --git a/ui/src/contexts/WorkspacesContext.tsx b/ui/src/contexts/WorkspacesContext.tsx index f76ce4d86..195748768 100644 --- a/ui/src/contexts/WorkspacesContext.tsx +++ b/ui/src/contexts/WorkspacesContext.tsx @@ -419,8 +419,16 @@ export function WorkspacesProvider({ children }: { children: ReactNode }) { prompt: string, agent: string, credentialSlug?: string, + model?: string | null, + reasoningEffort?: import('../api').ModelReasoningEffort, ): Promise => { - const result = await apiQuickStartWorkspaceManager(prompt, agent, credentialSlug) + const result = await apiQuickStartWorkspaceManager( + prompt, + agent, + credentialSlug, + model, + reasoningEffort, + ) setWorkspaceManager(result.manager) setWorkspaceManagerLoaded(true) setWorkspaceManagerError(null) diff --git a/ui/src/contexts/workspaces-context.ts b/ui/src/contexts/workspaces-context.ts index 03e248914..191f3e903 100644 --- a/ui/src/contexts/workspaces-context.ts +++ b/ui/src/contexts/workspaces-context.ts @@ -40,6 +40,8 @@ export interface WorkspacesContextValue { prompt: string, agent: string, credentialSlug?: string, + model?: string | null, + reasoningEffort?: import('../api').ModelReasoningEffort, ): Promise spawn(wsId: string, opts?: SpawnOpts, source?: WorkspaceSource): Promise openHeadlessRun( diff --git a/ui/src/demo/handlers/workspaces.ts b/ui/src/demo/handlers/workspaces.ts index acbd6276a..e810c4e60 100644 --- a/ui/src/demo/handlers/workspaces.ts +++ b/ui/src/demo/handlers/workspaces.ts @@ -364,7 +364,13 @@ export const workspacesHandlers = [ }, })), http.post('/api/workspaces/manager/quick-start', async ({ request }) => { - const body = await request.json().catch(() => ({})) as { prompt?: string } + const body = await request.json().catch(() => ({})) as { + prompt?: string + agent?: string + credentialSlug?: string + model?: string + reasoningEffort?: string + } demoManagerMessages = [ { role: 'user', content: body.prompt ?? 'Audit the active Workspace floor.' }, { role: 'assistant', content: 'Demo manager: active desks are inventoried and ready for coordination.' }, diff --git a/ui/src/hooks/useAgentLaunchConfig.ts b/ui/src/hooks/useAgentLaunchConfig.ts index 666452252..ab9617985 100644 --- a/ui/src/hooks/useAgentLaunchConfig.ts +++ b/ui/src/hooks/useAgentLaunchConfig.ts @@ -130,6 +130,21 @@ export function resolveAgentLaunchAiDetails( // context limits, so keep that fact unknown instead of borrowing a provider // default. if (!needsCredential) { + if ( + effectiveCredential && + credential?.slug === effectiveCredential && + (!hasWorkspace || detected?.slug !== effectiveCredential) + ) { + const creationModel = !hasWorkspace && creationDefault?.credentialSlug === effectiveCredential + ? creationDefault.model + : undefined + return { + model: creationModel ?? credential.resolvedModel ?? null, + contextWindow: null, + ...injectedReasoningDetails(credential), + source: 'new-injection', + } + } if (hasWorkspace && detected?.configured === true) { return { model: detected.model ?? ( @@ -162,7 +177,7 @@ export function resolveAgentLaunchAiDetails( // on-disk config, so keep its real model/context visible instead of falling // back to an empty summary. if (hasWorkspace && detected?.configured === true && ( - !effectiveCredential || detected.slug === null || detected.slug === effectiveCredential + !effectiveCredential || detected.slug === effectiveCredential )) { return { model: detected.model, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index f9576eb53..c282b8ce7 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -901,15 +901,15 @@ export const en = { reasoningDisabledSummary: 'Reasoning off', reasoningRuntimeSummary: 'Runtime reasoning', contextSummary: '{{limit}} context', - workspaceAiWillInject: 'Sending will configure this workspace with the selected AI provider.', - newWorkspaceAiWillSeed: 'This AI configuration will be applied to the new workspace when you send.', + workspaceAiWillInject: 'This provider applies only to the new Session; Workspace settings stay unchanged.', + newWorkspaceAiWillSeed: 'This provider applies only to the new Session when you send.', adjustWorkspaceAi: 'Adjust workspace AI', configureWorkspaceAi: 'Configure workspace AI', providerSettings: 'Provider settings', claudeOnboardingRequired: "Claude Code still needs its own first-run setup. Complete it in the TUI before continuing; OpenAlice leaves Claude's login state untouched.", claudeWorkspaceTrustRequired: 'Claude Code will ask you to trust this workspace once before the queued prompt continues.', noCredBody: '{{name}} has no AI provider configured. Add one to start chatting.', - credOverwrite: "Sending switches this workspace's provider from {{from}} to {{to}}.", + credOverwrite: 'The new Session will use {{to}} instead of the Workspace default {{from}}.', send: 'Send', attach: 'Attach', attachSoon: 'Attachments coming soon', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 081a2bb0e..ec6e35fb7 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -890,15 +890,15 @@ export const ja: Resources = { reasoningDisabledSummary: '推論オフ', reasoningRuntimeSummary: 'ランタイム既定の推論', contextSummary: '{{limit}} コンテキスト', - workspaceAiWillInject: '送信すると、選択した AI プロバイダーでこのワークスペースを設定します。', - newWorkspaceAiWillSeed: '送信すると、この AI 設定が新しいワークスペースに適用されます。', + workspaceAiWillInject: '選択したプロバイダーは新しい Session のみに適用され、Workspace 設定は変更されません。', + newWorkspaceAiWillSeed: '送信すると、選択したプロバイダーは新しい Session のみに適用されます。', adjustWorkspaceAi: 'ワークスペース AI を調整', configureWorkspaceAi: 'ワークスペース AI を設定', providerSettings: 'プロバイダー設定', claudeOnboardingRequired: 'Claude Code 側の初回セットアップが必要です。TUI で完了してから続行してください。OpenAlice は Claude のログイン状態を変更しません。', claudeWorkspaceTrustRequired: 'Claude Code は、このワークスペースを初めて開くときに信頼確認を表示します。一度承認すると、待機中のプロンプトが続行されます。', noCredBody: '{{name}} には AI プロバイダーが設定されていません。追加するとチャットを開始できます。', - credOverwrite: '送信すると、この workspace のプロバイダーが {{from}} から {{to}} に切り替わります。', + credOverwrite: '新しい Session は Workspace の既定値 {{from}} ではなく {{to}} を使用します。', send: '送信', attach: '添付', attachSoon: '添付機能は近日公開', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index 7255c4554..08967401e 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -897,15 +897,15 @@ export const zhHant: Resources = { reasoningDisabledSummary: '推理已關閉', reasoningRuntimeSummary: '執行環境預設推理', contextSummary: '{{limit}} 上下文', - workspaceAiWillInject: '傳送後,將使用所選 AI 提供者設定此工作區。', - newWorkspaceAiWillSeed: '傳送後,此 AI 設定將套用至新工作區。', + workspaceAiWillInject: '所選提供者只用於新 Session,不會修改 Workspace 設定。', + newWorkspaceAiWillSeed: '傳送後,所選提供者只用於新 Session。', adjustWorkspaceAi: '調整工作區 AI', configureWorkspaceAi: '設定工作區 AI', providerSettings: '供應方設定', claudeOnboardingRequired: 'Claude Code 仍需完成它自己的首次設定。請在 TUI 中完成後繼續;OpenAlice 不會改動 Claude 的登入狀態。', claudeWorkspaceTrustRequired: 'Claude Code 首次進入此工作區時會要求確認信任;確認一次後,已排隊的提示會繼續執行。', noCredBody: '{{name}} 尚未設定 AI 供應方,先新增一個才能開始對話。', - credOverwrite: '傳送會把此 workspace 的供應方從 {{from}} 切換為 {{to}}。', + credOverwrite: '新 Session 將使用 {{to}},而不是 Workspace 預設的 {{from}}。', send: '傳送', attach: '附件', attachSoon: '附件功能即將推出', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index d7f6886a8..a7cd6fc7f 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -889,15 +889,15 @@ export const zh: Resources = { reasoningDisabledSummary: '思考已关闭', reasoningRuntimeSummary: '运行时默认思考', contextSummary: '{{limit}} 上下文', - workspaceAiWillInject: '发送后,将使用所选 AI 提供商配置此工作区。', - newWorkspaceAiWillSeed: '发送后,此 AI 配置将应用到新工作区。', + workspaceAiWillInject: '所选提供商仅用于新 Session,不会修改 Workspace 设置。', + newWorkspaceAiWillSeed: '发送后,所选提供商仅用于新 Session。', adjustWorkspaceAi: '调整工作区 AI', configureWorkspaceAi: '配置工作区 AI', providerSettings: '提供方设置', claudeOnboardingRequired: 'Claude Code 仍需完成它自己的首次设置。请在 TUI 中完成后继续;OpenAlice 不会改动 Claude 的登录状态。', claudeWorkspaceTrustRequired: 'Claude Code 首次进入此工作区时会要求确认信任;确认一次后,已排队的提示会继续执行。', noCredBody: '{{name}} 还没有配置 AI 提供方,先添加一个才能开始对话。', - credOverwrite: '发送会把此 workspace 的提供方从 {{from}} 切换为 {{to}}。', + credOverwrite: '新 Session 将使用 {{to}},而不是 Workspace 默认的 {{from}}。', send: '发送', attach: '附件', attachSoon: '附件功能即将上线', diff --git a/ui/src/pages/ChatLandingPage.render.spec.tsx b/ui/src/pages/ChatLandingPage.render.spec.tsx index 481184c20..9c9f06843 100644 --- a/ui/src/pages/ChatLandingPage.render.spec.tsx +++ b/ui/src/pages/ChatLandingPage.render.spec.tsx @@ -406,6 +406,68 @@ describe('ChatLandingPage keyboard submission', () => { expect(mocks.probeAgentRuntimeReadiness).not.toHaveBeenCalled() expect(screen.queryByText('The runtime reported an error: 429: balance exhausted')).toBeNull() }) + + it('submits an explicit login-backed credential with its own model instead of the Workspace model', async () => { + const nativePiAgent: AgentInfo = { + ...piAgent, + capabilities: { + ...piAgent.capabilities, + aiProvider: { + ...piAgent.capabilities.aiProvider!, + credentialSource: 'runtime-or-workspace', + }, + }, + } + mocks.useWorkspaces.mockImplementation(() => ({ + ...context(workspaces), + agents: [nativePiAgent], + })) + mocks.listAgentCredentials.mockResolvedValue([ + { + slug: 'glm-1', + vendor: 'glm', + authType: 'api-key', + wires: { 'openai-chat': 'https://open.bigmodel.cn/api/paas/v4' }, + resolvedModel: 'glm-5.2', + }, + { + slug: 'deepseek-1', + vendor: 'deepseek', + authType: 'api-key', + wires: { 'openai-chat': 'https://api.deepseek.com' }, + resolvedModel: 'deepseek-v4-flash', + resolvedReasoningEffort: 'high', + }, + ]) + mocks.detectWorkspaceCredential.mockResolvedValue({ + configured: true, + slug: 'glm-1', + model: 'glm-5.2', + contextWindow: 256_000, + wireShape: 'openai-chat', + }) + + render() + + expect(await screen.findByLabelText('Model glm-5.2')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'AI provider' })) + fireEvent.click(screen.getByRole('menuitem', { name: /deepseek-1/ })) + expect(await screen.findByLabelText('Model deepseek-v4-flash')).toBeTruthy() + expect(screen.getByText('This provider applies only to the new Session; Workspace settings stay unchanged.')).toBeTruthy() + + fireEvent.change(screen.getByPlaceholderText('Ask Alice…'), { target: { value: 'Use DeepSeek.' } }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + + await waitFor(() => expect(mocks.quickChat).toHaveBeenCalledWith( + 'Use DeepSeek.', + 'pi', + 'deepseek-1', + 'chat-1', + 'chat', + 'deepseek-v4-flash', + 'high', + )) + }) }) describe('ChatLandingPage AI source disclosure', () => { @@ -445,7 +507,7 @@ describe('ChatLandingPage AI source disclosure', () => { render() - expect(await screen.findByText('Sending will configure this workspace with the selected AI provider.')).toBeTruthy() + expect(await screen.findByText('This provider applies only to the new Session; Workspace settings stay unchanged.')).toBeTruthy() expect(screen.getByLabelText('Model gemini-3.1-flash-lite')).toBeTruthy() expect(screen.getByRole('button', { name: 'Configure workspace AI' })).toBeTruthy() expect(screen.getByLabelText('minimal reasoning')).toBeTruthy() diff --git a/ui/src/pages/ChatLandingPage.spec.ts b/ui/src/pages/ChatLandingPage.spec.ts index 47116d6e6..c8ed32f36 100644 --- a/ui/src/pages/ChatLandingPage.spec.ts +++ b/ui/src/pages/ChatLandingPage.spec.ts @@ -395,7 +395,55 @@ describe('resolveAgentLaunchAiDetails', () => { }) }) - it('keeps native login fallback visible when no login-backed Workspace override exists', () => { + it('uses an explicitly selected login-backed credential model instead of the old Workspace model', () => { + expect(resolveAgentLaunchAiDetails( + false, + 'deepseek-1', + { + slug: 'deepseek-1', + resolvedModel: 'deepseek-v4-flash', + resolvedContextWindow: 128_000, + resolvedReasoningEffort: 'medium', + }, + { + configured: true, + slug: 'glm-1', + model: 'glm-5.2', + contextWindow: 256_000, + wireShape: 'openai-chat', + }, + undefined, + true, + )).toEqual({ + model: 'deepseek-v4-flash', + contextWindow: null, + reasoningEffort: 'medium', + source: 'new-injection', + }) + }) + + it('lets an explicit credential replace a hand-edited Workspace provider', () => { + expect(resolveAgentLaunchAiDetails( + true, + 'google-1', + credential, + { + configured: true, + slug: null, + model: 'hand-edited-model', + contextWindow: 32_000, + wireShape: 'openai-chat', + }, + undefined, + true, + )).toEqual({ + model: 'gemini-3.5-flash', + contextWindow: 1_048_576, + source: 'new-injection', + }) + }) + + it('shows the remembered login-backed override that the next Session will launch', () => { expect(resolveAgentLaunchAiDetails( false, 'minimax-1', @@ -409,7 +457,11 @@ describe('resolveAgentLaunchAiDetails', () => { }, undefined, true, - )).toBeNull() + )).toEqual({ + model: 'MiniMax-M2.5', + contextWindow: null, + source: 'new-injection', + }) }) it('previews a login-backed creation default without claiming a runtime context limit', () => { diff --git a/ui/src/pages/ChatLandingPage.tsx b/ui/src/pages/ChatLandingPage.tsx index 92cb63d87..fb5a77d7f 100644 --- a/ui/src/pages/ChatLandingPage.tsx +++ b/ui/src/pages/ChatLandingPage.tsx @@ -484,8 +484,9 @@ function HarnessLandingPage({ )} - {/* The selected cred differs from the one today's workspace already uses - — sending switches it. A notice, not a block (the user chose it). */} + {/* The selected credential differs from this Workspace's creation + default. It applies only to the new Session; make that divergence + visible without implying that Send rewrites Workspace files. */} {launchConfig.willOverwriteCredential && launchConfig.credential && (
{t('chatLanding.credOverwrite', { diff --git a/ui/src/pages/WorkspaceManagerPage.spec.tsx b/ui/src/pages/WorkspaceManagerPage.spec.tsx index 2eac0b2bf..ec80bf7cd 100644 --- a/ui/src/pages/WorkspaceManagerPage.spec.tsx +++ b/ui/src/pages/WorkspaceManagerPage.spec.tsx @@ -261,6 +261,8 @@ describe('WorkspaceManagerPage runtime selection', () => { '检查工作区', 'codex', undefined, + undefined, + undefined, )) }) @@ -294,6 +296,8 @@ describe('WorkspaceManagerPage runtime selection', () => { 'Inspect without a preflight.', 'codex', undefined, + undefined, + undefined, )) expect(mocks.probeAgentRuntimeReadiness).not.toHaveBeenCalled() }) @@ -336,6 +340,8 @@ describe('WorkspaceManagerPage runtime selection', () => { 'Inspect the floor.', 'claude', undefined, + undefined, + undefined, )) expect(mocks.setDefaultAgent).not.toHaveBeenCalled() expect(mocks.openOrFocus).toHaveBeenCalledWith({ @@ -443,6 +449,8 @@ describe('WorkspaceManagerPage runtime selection', () => { 'Audit issues.', 'pi', 'deepseek-1', + 'deepseek-chat', + undefined, )) }) diff --git a/ui/src/pages/WorkspaceManagerPage.tsx b/ui/src/pages/WorkspaceManagerPage.tsx index 7b6b85bad..6771d000a 100644 --- a/ui/src/pages/WorkspaceManagerPage.tsx +++ b/ui/src/pages/WorkspaceManagerPage.tsx @@ -99,6 +99,8 @@ export function WorkspaceManagerPage({ spec }: { spec: ManagerSpec }) { prompt, effectiveAgent, launchConfig.launchCredentialSlug, + launchConfig.aiDetails?.model, + launchConfig.aiDetails?.reasoningEffort, ) setDraft('') openOrFocus({ kind: 'workspace-manager', params: { sessionId: result.session.id } })