From 60b6e8defebdaec936830395b5ac412d1c9dca77 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:03:15 +0800 Subject: [PATCH 1/2] Separate conversation creation and support explicit runtime selection --- .../skills/alice/references/collaboration.md | 16 +++++- default/skills/delegate-autoquant/SKILL.md | 2 +- default/skills/workspace-manager/SKILL.md | 6 +-- docs/conversation-provenance.md | 16 ++++++ docs/model-semantics-and-runtime-injection.md | 13 +++++ src/core/workspace-tool-center.ts | 2 + src/server/cli-commands.ts | 1 + src/tool/conversation.spec.ts | 25 +++++++++ src/tool/conversation.ts | 51 ++++++++++++++++--- src/workspaces/context-injector.spec.ts | 4 +- src/workspaces/conversation-control.spec.ts | 3 +- src/workspaces/conversation-control.ts | 4 +- .../service.issue-assignee-session.spec.ts | 32 ++++++++++++ src/workspaces/service.ts | 21 +++++--- .../session-runtime-binding.spec.ts | 16 ++++++ src/workspaces/session-runtime-binding.ts | 25 +++++++++ 16 files changed, 214 insertions(+), 23 deletions(-) diff --git a/default/skills/alice/references/collaboration.md b/default/skills/alice/references/collaboration.md index 7a6989e0c..5a66060d1 100644 --- a/default/skills/alice/references/collaboration.md +++ b/default/skills/alice/references/collaboration.md @@ -34,7 +34,7 @@ Use `conversation`, not Inbox, for ordinary coworker communication. alice peer list # Recruit a fresh Session at that Workspace for new work. -alice conversation ask --ws-id \ +alice conversation create --ws-id \ --prompt 'Investigate this bounded question and report back.' # Continue one exact attributable product Session. @@ -46,7 +46,7 @@ alice conversation ask --inbox-id \ --prompt 'What did you send, and what should I inspect first?' --await # Recruit a fresh Session in a Harness default Workspace. -alice conversation ask --harness autoquant \ +alice conversation create --harness autoquant \ --prompt 'Start a new quantitative research assignment.' ``` @@ -56,6 +56,18 @@ requires the explicitly initialized AutoQuant default Workspace and never creates or guesses one. Both launch a fresh product Session in the resolved desk; use the returned `resumeId` for later continuation. +`create` always creates a new Session and sends its first prompt. `ask --resume-id` +continues an existing Session. Keep `resumeId` as the coworker's address and +`taskId` as one turn's execution handle. + +Both commands accept optional `--credential ` or +`--credential-source native` (mutually exclusive), `--model `, and +`--effort `. Never pass API keys. New Sessions inherit Workspace headless +preferences; existing Sessions retain their own binding for omitted fields. +Changing credential drops inherited model/effort from the previous credential. +Explicit changes to an existing Session persist for later turns and require it +to be idle. Its Agent runtime cannot change. `--agent` selects only a new worker. + Prompts are ordinary coworker messages. Add `--reconstruct` only when the task explicitly requires a fresh worker to reconstruct missing historical intent. Provenance may still report `resolution.mode: reconstructed` without changing diff --git a/default/skills/delegate-autoquant/SKILL.md b/default/skills/delegate-autoquant/SKILL.md index 68a465267..0d286c6bf 100644 --- a/default/skills/delegate-autoquant/SKILL.md +++ b/default/skills/delegate-autoquant/SKILL.md @@ -35,7 +35,7 @@ correct handoff. Use the `alice` collaboration surface: ```bash -alice conversation ask --harness autoquant --await --prompt ' +alice conversation create --harness autoquant --await --prompt ' Research question: Decision this supports: Caller-owned scope and constraints: diff --git a/default/skills/workspace-manager/SKILL.md b/default/skills/workspace-manager/SKILL.md index 460db4c78..924cc72d9 100644 --- a/default/skills/workspace-manager/SKILL.md +++ b/default/skills/workspace-manager/SKILL.md @@ -29,9 +29,9 @@ alice peer path --id alice peer sessions --id alice conversation ask --resume-id --prompt "..." --await # Recruit a fresh coworker for new work: -alice conversation ask --ws-id --prompt "..." +alice conversation create --ws-id --prompt "..." # Explicit historical reconstruction when no attributable Session exists: -alice conversation ask --ws-id --prompt "..." --reconstruct --await +alice conversation create --ws-id --prompt "..." --reconstruct --await ``` This distinction is not cosmetic. `--resume-id` continues the exact coworker @@ -54,7 +54,7 @@ instructions. - Prefer asking an attributable `resumeId` when one is known. Recent Session titles in `peer list` are hints; use `peer sessions --id` only for the few relevant desks to resolve the exact identity. Otherwise recruit a worker from - the relevant Workspace with `conversation ask --ws-id`. Add `--reconstruct` + the relevant Workspace with `conversation create --ws-id`. Add `--reconstruct` only for missing historical intent and never claim the fresh worker carries the original coworker's memory. - Use `--await` for a short answer needed now. For delegated work or several diff --git a/docs/conversation-provenance.md b/docs/conversation-provenance.md index 9fb1c322d..2d3a4ba50 100644 --- a/docs/conversation-provenance.md +++ b/docs/conversation-provenance.md @@ -807,3 +807,19 @@ of stderr for failed/interrupted tasks, with `stderrTruncated` indicating a clipped log. Existing tasks can recover this diagnostic from their log file; missing logs still leave an exit/signal or generic failure explanation. Logs remain diagnostics, never assistant replies. + + +### Explicit creation and follow-up selection + +`alice conversation create --ws-id | --harness --prompt ` +creates a new Session and dispatches its first turn. `conversation ask +--resume-id ` continues an existing Session; author addressing remains on +`ask`. Legacy `ask --ws-id/--harness` remains supported for copied Skills. + +Both accept optional `credential` (vault slug), `credentialSource: native`, +`model`, and `effort`. Credential forms are mutually exclusive. Creation merges +with headless Workspace preferences. Exact follow-up patches only the Session's +binding under the execution claim: omitted fields retain that binding, changing +credential discards inherited model/effort, and explicit fields persist for +subsequent turns. Busy Sessions reject before editing the binding. Runtime +identity cannot change. No new persisted format is introduced. diff --git a/docs/model-semantics-and-runtime-injection.md b/docs/model-semantics-and-runtime-injection.md index 14a3c79c5..7c1d11036 100644 --- a/docs/model-semantics-and-runtime-injection.md +++ b/docs/model-semantics-and-runtime-injection.md @@ -585,3 +585,16 @@ namespaces that many remote containers cannot create. Do not implement this by rewriting global user configuration. Native runtime enterprise policies and OS permissions remain authoritative. UTA still owns trading permissions; these launch settings do not change its trading mode. + + +### CLI conversation selection + +`conversation create` accepts credential/model/effort overrides for a new +Session; `conversation ask` accepts the same optional dimensions for an idle +existing Session. Credential is a vault slug or explicit native access, never +secret material. Follow-up edits patch the stored binding under the headless +execution claim and do not consult Workspace defaults. Changing credential +clears inherited model/effort; omitted fields otherwise retain the Session's +selection. Runtime identity remains fixed. Web paused-Session editing and CLI +selection both resolve through `createSessionRuntimeBinding` and persist via +`replaceRuntimeBinding`. diff --git a/src/core/workspace-tool-center.ts b/src/core/workspace-tool-center.ts index 1dd1a4b9e..13d207eb7 100644 --- a/src/core/workspace-tool-center.ts +++ b/src/core/workspace-tool-center.ts @@ -25,6 +25,7 @@ */ import type { Tool } from 'ai' +import type { SessionRuntimeSelection } from '../workspaces/session-runtime-binding.js' import type { IInboxStore, InboxEntry, InboxOrigin } from './inbox-store.js' import type { IEntityStore } from './entity-store.js' import type { IProvenanceStore } from './provenance-store.js' @@ -153,6 +154,7 @@ export interface WorkspaceConversationControl { readonly timeoutMs?: number readonly target: WorkspaceConversationTarget readonly agent?: string + readonly selection?: SessionRuntimeSelection /** Add the artifact-reconstruction preamble when a fresh fallback worker is * required. Provenance may still resolve as reconstructed when this is * false; prompt semantics and attribution are deliberately independent. */ diff --git a/src/server/cli-commands.ts b/src/server/cli-commands.ts index f0af33a5e..b8ab6683c 100644 --- a/src/server/cli-commands.ts +++ b/src/server/cli-commands.ts @@ -156,6 +156,7 @@ const BASE_EXPORTS: Record = { sessions: 'workspace_sessions', }, conversation: { + create: 'conversation_create', ask: 'conversation_ask', await: 'conversation_await', collect: 'conversation_collect', diff --git a/src/tool/conversation.spec.ts b/src/tool/conversation.spec.ts index 9c74036f1..8d3da7326 100644 --- a/src/tool/conversation.spec.ts +++ b/src/tool/conversation.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { createMemoryInboxStore } from '../core/inbox-store.js' import type { WorkspaceToolContext } from '../core/workspace-tool-center.js' import { + conversationCreateFactory, conversationAskFactory, conversationAwaitFactory, conversationCollectFactory, @@ -378,3 +379,27 @@ describe('conversation diagnostics projection', () => { expect(taskProjection({ ...completedTask, error: 'warning' }, 'summary')).not.toHaveProperty('error') }) }) + + +describe('conversation creation and selection', () => { + it('creates in a Harness with the complete explicit runtime selection', async () => { + const ask = vi.fn(async () => ({ status: 'dispatched', taskId: 't', resumeId: 'r', workspaceId: 'w', workspace: 'w', agent: 'codex', resolution: { mode: 'reconstructed' } })) + const tool = conversationCreateFactory.build(context({ conversation: { ask, read: vi.fn() } as never })) + await run(tool, { harness: 'autoquant', prompt: 'work', agent: 'codex', credentialSource: 'native', model: 'custom-model', effort: 'medium' }) + expect(ask).toHaveBeenCalledWith(expect.objectContaining({ + target: { kind: 'harness', harness: 'autoquant' }, agent: 'codex', + selection: { credentialSource: 'native', model: 'custom-model', reasoningEffort: 'medium' }, + })) + }) + it('rejects conflicting authentication without dispatching', async () => { + const ask = vi.fn() + const tool = conversationCreateFactory.build(context({ conversation: { ask, read: vi.fn() } })) + expect(await run(tool, { wsId: 'w', prompt: 'work', credential: 'saved', credentialSource: 'native' })).toMatchObject({ ok: false }) + expect(ask).not.toHaveBeenCalled() + }) + it('forwards a partial follow-up selection without inventing defaults', async () => { + const ask = vi.fn(async () => ({ status: 'unavailable', resolution: { mode: 'unavailable', reason: 'missing' } })) + await run(conversationAskFactory.build(context({ conversation: { ask, read: vi.fn() } as never })), { resumeId: 'r', prompt: 'continue', effort: 'high' }) + expect(ask).toHaveBeenCalledWith(expect.objectContaining({ selection: { reasoningEffort: 'high' }, target: { kind: 'resume', resumeId: 'r' } })) + }) +}) diff --git a/src/tool/conversation.ts b/src/tool/conversation.ts index 250d9cdd9..eff31bb98 100644 --- a/src/tool/conversation.ts +++ b/src/tool/conversation.ts @@ -1,5 +1,7 @@ import { headlessFailureSummary } from '../workspaces/headless-failure.js' import { tool } from 'ai' +import { MODEL_REASONING_EFFORTS } from '../ai-providers/model-semantics.js' +import type { SessionRuntimeSelection } from '../workspaces/session-runtime-binding.js' import { z } from 'zod' import type { @@ -17,9 +19,16 @@ const MAX_TIMEOUT_MS = 2_147_478_647 const MAX_PROMPT_CHARS = 16_000 const AWAIT_POLL_MS = 250 +const conversationSelectionShape = { + credential: z.string().min(1).optional().describe('OpenAlice vault credential slug, never an API key. Omit to retain the Session or Workspace selection.'), + credentialSource: z.literal('native').optional().describe('Use the runtime own authentication; mutually exclusive with credential.'), + model: z.string().min(1).optional().describe('Optional model id; custom model ids are accepted.'), + effort: z.enum(MODEL_REASONING_EFFORTS).optional().describe('Optional reasoning effort.'), +} + export const conversationAskCommonShape = { prompt: z.string().trim().min(1).max(MAX_PROMPT_CHARS) - .describe('Question for the responsible Session or reconstructing worker.'), + .describe('First task for a new Session, or a follow-up message for an existing Session.'), agent: z.string().min(1).optional() .describe('Optional runtime for reconstructed/fresh work only; exact Session runtime cannot be overridden.'), timeoutMs: z.coerce.number().int().positive().max(MAX_TIMEOUT_MS).optional() @@ -85,6 +94,7 @@ export async function askWorkspaceConversation( target: WorkspaceConversationTarget subject?: HeadlessInquirySubject agent?: string + selection?: SessionRuntimeSelection timeoutMs?: number await?: boolean reconstruct?: boolean @@ -97,6 +107,7 @@ export async function askWorkspaceConversation( const result = await ctx.conversation.ask({ prompt: input.prompt, target: input.target, + ...(input.selection ? { selection: input.selection } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), source: sessionOriginFromInboxOrigin(ctx.workspaceId, ctx.origin) ?? { kind: 'workspace', @@ -175,7 +186,7 @@ export const conversationAskFactory: WorkspaceToolFactory = { build(ctx) { return tool({ description: [ - "Ask a known product Session, an Inbox sender, an Issue's attributable creator, or a fresh worker.", + "Send a follow-up to a Session or attributable author. Use conversation create for a new Session; fresh-target flags here remain compatibility aliases.", '', 'Use exactly one addressing form: resumeId for an exact Session; inboxId for the', 'sender of one delivery; issueId (optionally scoped by wsId) for Issue creation', @@ -191,6 +202,7 @@ export const conversationAskFactory: WorkspaceToolFactory = { ].join('\n'), inputSchema: z.object({ ...conversationAskCommonShape, + ...conversationSelectionShape, resumeId: z.string().min(1).optional() .describe('Exact product Session to continue. Cannot be combined with another target flag.'), inboxId: z.string().min(1).optional() @@ -210,6 +222,7 @@ export const conversationAskFactory: WorkspaceToolFactory = { issueId, harness, agent, + credential, credentialSource, model, effort, timeoutMs, await: shouldAwait = false, reconstruct = false, @@ -217,6 +230,7 @@ export const conversationAskFactory: WorkspaceToolFactory = { if (!ctx.conversation) { return { ok: false as const, error: 'workspace conversation control is unavailable' } } + if (credential && credentialSource) return { ok: false, error: 'credential and credential-source are mutually exclusive' } const targetCount = Number(Boolean(resumeId)) + Number(Boolean(inboxId)) + Number(Boolean(issueId)) @@ -245,6 +259,12 @@ export const conversationAskFactory: WorkspaceToolFactory = { : { kind: 'workspace' as const, workspaceId: wsId! } const result = await askWorkspaceConversation(ctx, { prompt, + ...((credential || credentialSource || model || effort) ? { selection: { + ...(credential ? { credentialSlug: credential } : {}), + ...(credentialSource ? { credentialSource } : {}), + ...(model ? { model } : {}), + ...(effort ? { reasoningEffort: effort } : {}), + } } : {}), target, ...(inboxAddress ? { subject: inboxAddress.subject } : {}), ...(agent ? { agent } : {}), @@ -260,6 +280,24 @@ export const conversationAskFactory: WorkspaceToolFactory = { }, } +/** Fresh creation has its own manifest, so no author/Session address can slip in. */ +export const conversationCreateFactory: WorkspaceToolFactory = { + name: 'conversation_create', + build(ctx) { + const ask = conversationAskFactory.build(ctx) + return tool({ + description: 'Create a new Session and deliver its first prompt. Choose exactly one Workspace or Harness. Retain resumeId and use conversation ask for follow-ups; taskId identifies only this turn.', + inputSchema: z.object({ + ...conversationAskCommonShape, + ...conversationSelectionShape, + wsId: z.string().min(1).optional().describe('Workspace in which to create a new Session.'), + harness: z.enum(['chat', 'autoquant', 'prediction']).optional().describe('Create in this Harness default Workspace.'), + }), + execute: async (input, options) => ask.execute!(input, options), + }) + }, +} + export const conversationAwaitFactory: WorkspaceToolFactory = { name: 'conversation_await', build(ctx) { @@ -267,13 +305,13 @@ export const conversationAwaitFactory: WorkspaceToolFactory = { description: [ 'Wait server-side for one conversation task to finish.', '', - 'Use after dispatching several conversation_ask calls so their headless runs execute', + 'Use after dispatching several conversation_create or conversation_ask calls so their headless runs execute', 'concurrently. This replaces hand-written sleep loops. With an explicit wait budget,', 'an expired wait returns while the task keeps running; without one, this waits until', 'the task reaches a terminal state.', ].join('\n'), inputSchema: z.object({ - taskId: z.string().min(1).describe('Short taskId returned by conversation_ask.'), + taskId: z.string().min(1).describe('Short taskId returned by conversation create or ask.'), timeoutMs: z.coerce.number().int().positive().max(MAX_TIMEOUT_MS).optional() .describe('Optional server-side wait budget in milliseconds. Omit to wait until the task finishes.'), }), @@ -366,14 +404,14 @@ export const conversationReadFactory: WorkspaceToolFactory = { build(ctx) { return tool({ description: [ - 'Read one headless follow-up started by conversation_ask.', + 'Read one turn started by conversation create or ask.', '', 'Summary returns the latest assistant reply and one compact failure when present.', 'Tool activity and normalized message blocks are available only in detailed mode.', 'Running tasks may have partial output.', ].join('\n'), inputSchema: z.object({ - taskId: z.string().min(1).describe('taskId returned by conversation_ask.'), + taskId: z.string().min(1).describe('taskId returned by conversation create or ask.'), mode: z.enum(['summary', 'detailed']).optional().default('summary') .describe('`summary` returns status and assistant text; `detailed` also returns normalized tool, error, and message blocks.'), }), @@ -397,6 +435,7 @@ export const conversationReadFactory: WorkspaceToolFactory = { } export const conversationToolFactories: WorkspaceToolFactory[] = [ + conversationCreateFactory, conversationAskFactory, conversationAwaitFactory, conversationCollectFactory, diff --git a/src/workspaces/context-injector.spec.ts b/src/workspaces/context-injector.spec.ts index 6104cc9b9..15511f2b1 100644 --- a/src/workspaces/context-injector.spec.ts +++ b/src/workspaces/context-injector.spec.ts @@ -113,7 +113,7 @@ describe('injectWorkspaceContext — skills', () => { }); for (const root of ['.claude/skills', '.agents/skills']) { const skill = await read(`${root}/delegate-autoquant/SKILL.md`); - expect(skill).toContain('alice conversation ask --harness autoquant'); + expect(skill).toContain('alice conversation create --harness autoquant'); expect(skill).toContain('The universal result is the Agent\'s ordinary `assistantText` handoff'); expect(skill).toContain('does not automatically publish either artifact to the'); expect(skill).toContain('Primary deliverable directory: '); @@ -145,7 +145,7 @@ describe('injectWorkspaceContext — skills', () => { expect(skill).toContain('alice peer path --id '); expect(skill).toContain("Coding Agent's native Read/Search/Glob/Git capabilities"); expect(skill).toContain('alice conversation ask --inbox-id '); - expect(skill).toContain('alice conversation ask --harness autoquant'); + expect(skill).toContain('alice conversation create --harness autoquant'); expect(skill).not.toContain('peer file-read'); }); diff --git a/src/workspaces/conversation-control.spec.ts b/src/workspaces/conversation-control.spec.ts index 0e354e6fe..3abd1d5c6 100644 --- a/src/workspaces/conversation-control.spec.ts +++ b/src/workspaces/conversation-control.spec.ts @@ -319,6 +319,7 @@ describe('Workspace conversation control', () => { const result = await createWorkspaceConversationControl(svc).ask({ target: { kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit' }, prompt: 'Why did you create this?', + selection: { model: 'custom-model', reasoningEffort: 'high' }, timeoutMs: 300_000, }) @@ -334,7 +335,7 @@ describe('Workspace conversation control', () => { undefined, 'resume-peer', undefined, - undefined, + { model: 'custom-model', reasoningEffort: 'high' }, expect.objectContaining({ originalPrompt: 'Why did you create this?', deliveredPrompt: 'Why did you create this?', diff --git a/src/workspaces/conversation-control.ts b/src/workspaces/conversation-control.ts index 0e79fcd13..eeab9aedb 100644 --- a/src/workspaces/conversation-control.ts +++ b/src/workspaces/conversation-control.ts @@ -359,7 +359,7 @@ export function createWorkspaceConversationControl( undefined, continuingOrigin?.resumeId, inquiry, - undefined, + input.selection, conversation, createdBy, ) @@ -371,7 +371,7 @@ export function createWorkspaceConversationControl( undefined, continuingOrigin?.resumeId, undefined, - undefined, + input.selection, conversation, createdBy, ) diff --git a/src/workspaces/service.issue-assignee-session.spec.ts b/src/workspaces/service.issue-assignee-session.spec.ts index 98a2d951c..c878316a7 100644 --- a/src/workspaces/service.issue-assignee-session.spec.ts +++ b/src/workspaces/service.issue-assignee-session.spec.ts @@ -163,3 +163,35 @@ it.each(['terminal', 'webpi'] as const)('hands %s ownership to an Issue turn and expect(service!.isResumeActive(resumeId)).toBe(false) } finally { release(); terminal.mockRestore(); web.mockRestore(); command.mockRestore() } }) + +it('persists explicit conversation edits while keeping busy and Issue dispatches from changing them', async () => { + const { createWorkspaceConversationControl } = await import('./conversation-control.js') + await service!.catalog.recordCreated(service!.registry.get('ws-1')!) + const resumeId = 'resume-selection-test' + await service!.sessionCoordinator.ensure({ + resumeId, wsId: 'ws-1', agent: 'codex', namePrefix: 'c', + agentSessionId: 'native-selection-test', state: 'paused', surface: 'headless', + runtimeBinding: { version: 1, credential: { source: 'native' }, model: 'test-model', reasoningEffort: 'medium' }, + }) + const adapter = service!.adapters.get('codex')! + const command = vi.spyOn(adapter, 'composeHeadlessCommand').mockReturnValue([ + process.execPath, '-e', `console.log(JSON.stringify({type:'item.completed',item:{type:'agent_message',text:'OK'}}));`, + ]) + try { + const control = createWorkspaceConversationControl(service!) + const input = { target: { kind: 'resume' as const, resumeId }, prompt: 'reply', selection: { reasoningEffort: 'high' as const } } + service!.claimResume(resumeId) + await expect(control.ask(input)).rejects.toMatchObject({ code: 'busy' }) + expect(service!.resumeRegistry.get(resumeId)?.runtimeBinding?.reasoningEffort).toBe('medium') + service!.releaseResume(resumeId) + const result = await control.ask(input) + if (result.status !== 'dispatched') throw new Error('not dispatched') + await vi.waitFor(() => expect(service!.isResumeActive(resumeId)).toBe(false), { timeout: 10000 }) + expect(service!.headlessTasks.get(result.taskId)).toMatchObject({ model: 'test-model', effort: 'high', status: 'done' }) + expect(service!.resumeRegistry.get(resumeId)?.runtimeBinding).toMatchObject({ model: 'test-model', reasoningEffort: 'high' }) + await expect(service!.dispatchHeadlessTask(service!.registry.get('ws-1')!, adapter, 'issue', undefined, + { kind: 'issue', workspaceId: 'ws-1', issueId: 'test' }, resumeId, undefined, { model: 'other-model' })) + .rejects.toMatchObject({ code: 'not_ready' }) + expect(service!.resumeRegistry.get(resumeId)?.runtimeBinding?.model).toBe('test-model') + } finally { command.mockRestore() } +}) diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index 1d549a8d4..73e94bee5 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -42,6 +42,7 @@ import { import { loadConfig, type ServerConfig } from './config.js'; import { createNativeSessionRuntimeBinding, + mergeSessionRuntimeSelection, createSessionRuntimeBinding, resolveSessionRuntimeBinding, type SessionRuntimeSelection, @@ -551,7 +552,7 @@ export interface WorkspaceService { resumeId?: string, /** Optional Inbox/Issue reverse link for a user-initiated inquiry. */ inquiry?: HeadlessTaskInquiry, - /** Fresh-Session runtime selection. Ignored on exact resume, which replays its binding. */ + /** Optional selection. Fresh Sessions inherit Workspace defaults; exact resumes patch their own binding under the execution lock. */ selection?: SessionRuntimeSelection, /** Cross-Agent message metadata for the independent conversation log. */ conversation?: AgentConversationDispatch, @@ -1902,12 +1903,20 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions } nativeResume = { sessionId: identity.agentSessionId }; parentTaskId = identity.latestTaskId ?? headlessTasks.latestForResumeId(resumeId)?.taskId; - if (selection?.credentialSlug || selection?.model || selection?.reasoningEffort) { - throw new HeadlessResumeError('not_ready', 'a resumed Session reuses its persisted credential, model, and effort'); + const previous = identity.runtimeBinding ?? createNativeSessionRuntimeBinding({ adapter }).binding; + if (conversation && selection && Object.values(selection).some(value => value !== undefined)) { + sessionRuntime = await createSessionRuntimeBinding({ + adapter, cwd: ws.dir, selection: mergeSessionRuntimeSelection(previous, selection), + }); + await resumeRegistry.replaceRuntimeBinding({ + resumeId, wsId: ws.id, agent: adapter.id, runtimeBinding: sessionRuntime.binding, + }); + } else { + if (selection?.credentialSlug || selection?.model || selection?.reasoningEffort) { + throw new HeadlessResumeError('not_ready', 'a resumed Session reuses its persisted credential, model, and effort; edit it through Session settings or conversation ask'); + } + sessionRuntime = await resolveSessionRuntimeBinding({ adapter, cwd: ws.dir, binding: previous }); } - sessionRuntime = identity.runtimeBinding - ? await resolveSessionRuntimeBinding({ adapter, cwd: ws.dir, binding: identity.runtimeBinding }) - : createNativeSessionRuntimeBinding({ adapter }); } catch (error) { activeResumeIds.delete(resumeId); throw error; diff --git a/src/workspaces/session-runtime-binding.spec.ts b/src/workspaces/session-runtime-binding.spec.ts index b4561413e..8c2253ce2 100644 --- a/src/workspaces/session-runtime-binding.spec.ts +++ b/src/workspaces/session-runtime-binding.spec.ts @@ -19,6 +19,7 @@ import { ompAdapter } from './adapters/omp.js' import { opencodeAdapter } from './adapters/opencode.js' import { piAdapter } from './adapters/pi.js' import { + mergeSessionRuntimeSelection, createNativeSessionRuntimeBinding, createSessionRuntimeBinding, resolveSessionRuntimeBinding, @@ -393,3 +394,18 @@ describe('built-in Agent Session runtime projection', () => { } }) }) + + +describe('Session follow-up selection', () => { + const binding = { version: 1 as const, credential: { source: 'vault' as const, credentialSlug: 'a' }, model: 'model-a', reasoningEffort: 'medium' as const } + it('retains the Session selection on partial edits', () => { + expect(mergeSessionRuntimeSelection(binding, { reasoningEffort: 'high' })).toEqual({ credentialSlug: 'a', model: 'model-a', reasoningEffort: 'high' }) + }) + it('does not transfer the old model to a different credential', () => { + expect(mergeSessionRuntimeSelection(binding, { credentialSlug: 'b' })).toEqual({ credentialSlug: 'b' }) + expect(mergeSessionRuntimeSelection(binding, { credentialSource: 'native' })).toEqual({ credentialSource: 'native' }) + }) + it('keeps model choices when explicitly supplied with a new credential', () => { + expect(mergeSessionRuntimeSelection(binding, { credentialSource: 'native', model: 'new' })).toEqual({ credentialSource: 'native', model: 'new' }) + }) +}) diff --git a/src/workspaces/session-runtime-binding.ts b/src/workspaces/session-runtime-binding.ts index 588a5b819..0640377ba 100644 --- a/src/workspaces/session-runtime-binding.ts +++ b/src/workspaces/session-runtime-binding.ts @@ -69,6 +69,31 @@ export function createNativeSessionRuntimeBinding(input: { } } +/** Patch an existing Session without importing Workspace defaults or another credential's model. */ +export function mergeSessionRuntimeSelection( + binding: SessionRuntimeBinding, + selection: SessionRuntimeSelection, +): SessionRuntimeSelection { + const current = binding.credential + const changed = selection.credentialSource === 'native' + ? current.source !== 'native' + : selection.credentialSlug !== undefined + ? current.source !== 'vault' || current.credentialSlug !== selection.credentialSlug + : false + if (current.source === 'workspace' && !selection.credentialSource && !selection.credentialSlug) { + throw new SessionRuntimeBindingError('workspace_binding_changed', 'Select native authentication or a vault credential before editing a legacy Workspace binding') + } + return { + ...(selection.credentialSource ? { credentialSource: selection.credentialSource } + : selection.credentialSlug ? { credentialSlug: selection.credentialSlug } + : current.source === 'vault' ? { credentialSlug: current.credentialSlug } + : { credentialSource: 'native' as const }), + ...(!changed && binding.model ? { model: binding.model } : {}), + ...(!changed && binding.reasoningEffort ? { reasoningEffort: binding.reasoningEffort } : {}), + ...selection, + } +} + function providerFingerprint(ai: WorkspaceAiCred): string { return createHash('sha256').update(JSON.stringify({ baseUrl: ai.baseUrl ?? null, From 49da47c3c6d1bdf118abd804c06d4d9a291569e4 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:04:05 +0800 Subject: [PATCH 2/2] Keep creation target validation specific to new sessions --- src/tool/conversation.spec.ts | 9 +++++++++ src/tool/conversation.ts | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/tool/conversation.spec.ts b/src/tool/conversation.spec.ts index 8d3da7326..ca37e94d4 100644 --- a/src/tool/conversation.spec.ts +++ b/src/tool/conversation.spec.ts @@ -403,3 +403,12 @@ describe('conversation creation and selection', () => { expect(ask).toHaveBeenCalledWith(expect.objectContaining({ selection: { reasoningEffort: 'high' }, target: { kind: 'resume', resumeId: 'r' } })) }) }) + + +it('creation target errors only suggest creation addresses', async () => { + const ask = vi.fn() + const tool = conversationCreateFactory.build(context({ conversation: { ask, read: vi.fn() } })) + expect(await run(tool, { prompt: 'work' })).toMatchObject({ ok: false, error: expect.stringContaining('exactly one target: --ws-id or --harness') }) + expect(await run(tool, { prompt: 'work', wsId: 'w', harness: 'chat' })).toMatchObject({ ok: false }) + expect(ask).not.toHaveBeenCalled() +}) diff --git a/src/tool/conversation.ts b/src/tool/conversation.ts index eff31bb98..a24feb543 100644 --- a/src/tool/conversation.ts +++ b/src/tool/conversation.ts @@ -293,7 +293,12 @@ export const conversationCreateFactory: WorkspaceToolFactory = { wsId: z.string().min(1).optional().describe('Workspace in which to create a new Session.'), harness: z.enum(['chat', 'autoquant', 'prediction']).optional().describe('Create in this Harness default Workspace.'), }), - execute: async (input, options) => ask.execute!(input, options), + execute: async (input, options) => { + if (Number(Boolean(input.wsId)) + Number(Boolean(input.harness)) !== 1) { + return { ok: false, error: 'conversation create requires exactly one target: --ws-id or --harness; use conversation ask --resume-id to continue a Session' } + } + return ask.execute!(input, options) + }, }) }, }