fix(chat): send pi and acp messages after a model-only change (#32004)

This commit is contained in:
Kelvin Amoaba
2026-10-22 21:10:01 +00:00
committed by GitHub
parent e61d062ef1
commit 752f531b0a
7 changed files with 83 additions and 7 deletions
@@ -24,7 +24,8 @@ export async function applyAcpMessageOptions(
for (const key of ['model', 'effort'] as const) {
const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort)
if (value === undefined) {
if (session.options.picked.has(key) || session.options.reported()[key] !== undefined) {
const applied = session.options.applied.get(key)
if (applied !== undefined && applied !== initial[key]) {
throw new AgentSessionOptionRejectedError(
new Error(`The default ${key} for this model is unknown`)
)
+4 -1
View File
@@ -65,6 +65,8 @@ export class AcpStructuredOptions {
// A loaded session keeps the model it ran; only a new one resolves the agent's own config.
private resolvesConfig = false
readonly picked = new Set<string>()
/** Each key's value from Orca's last successful write; never the agent's report. */
readonly applied = new Map<string, string>()
private initialChoice: Readonly<Record<string, string>> = {}
currentProof = new Set<string>()
writeRevision = 0
@@ -250,7 +252,7 @@ export function writeAcpSessionOption(
connection: Pick<AcpStructuredConnection, 'setConfigOption' | 'setModel'>,
options: AcpStructuredOptions,
write: AcpOptionWrite,
bound: { agent: string; timeoutMs: number; signal?: AbortSignal; key: string }
bound: { agent: string; timeoutMs: number; signal?: AbortSignal; key: string; value: string }
): Promise<void> {
const { agent, timeoutMs, signal } = bound
if (signal?.aborted) {
@@ -267,6 +269,7 @@ export function writeAcpSessionOption(
}
apply()
options.currentProof.add(bound.key)
options.applied.set(bound.key, bound.value)
}
const applied =
write.method === 'config'
@@ -180,6 +180,35 @@ describe('ACP structured session adapter: turns', () => {
})
})
it('sends model-only choices away from the opening model, back, and away again', async () => {
const rig = await openAcpAdapterRig()
await rig.acquire()
for (const [index, [clientMessageId, model]] of [
['away', 'grok-4.6'],
['back', 'grok-4.7'],
['again', 'grok-4.6']
].entries()) {
await expect(
rig.adapter.dispatch({
sessionId: SESSION,
fence: 1,
clientMessageId,
body: hello,
executionOptions: { model }
})
).resolves.toEqual({ state: 'admitted' })
rig.child().agent.reply(await rig.frame('session/prompt', index), { stopReason: 'end_turn' })
await waitFor(() => expect(rig.adapter.holdsDispatch(SESSION)).toBe(false))
}
// Back on the opening model, its opening effort is restored; elsewhere the agent keeps its own.
expect(rig.sent('session/set_config_option').map((frame) => frame.params)).toMatchObject([
{ configId: 'model', value: 'grok-4.6' },
{ configId: 'model', value: 'grok-4.7' },
{ configId: 'reasoning_effort', value: 'high' },
{ configId: 'model', value: 'grok-4.6' }
])
})
it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => {
const rig = await openAcpAdapterRig({
deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } }
@@ -263,6 +263,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
// Bounded, and abandoned by a close or Stop: the session's queue waits on it.
await writeAcpSessionOption(session.connection, session.options, write, {
key: input.key,
value: input.value,
agent: session.spec.agent,
timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS,
...(input.signal ? { signal: input.signal } : {})
+2 -1
View File
@@ -73,7 +73,8 @@ export async function dispatchPiRpcMessage(
(key === 'model' || model === session.initialOptions.model
? session.initialOptions[key]
: undefined)
if (value === undefined && session.optionProof.writes.has(key)) {
const applied = session.selected.get(key)
if (value === undefined && applied !== undefined && applied !== session.initialOptions[key]) {
throw new AgentSessionOptionRejectedError(
new Error(`The default ${key} for this model is unknown`)
)
@@ -44,7 +44,10 @@ export class FakeConnection implements PiRpcConnection {
private readonly exitListeners: (() => void)[] = []
requestOverride?: (command: string) => Promise<unknown> | undefined
abortRequest?: () => void
constructor(readonly handlers: JsonlRpcAgentConnectionOptions) {}
constructor(
readonly handlers: JsonlRpcAgentConnectionOptions,
private readonly reported: { thinkingLevel?: string } = {}
) {}
async request(command: string): Promise<unknown> {
this.requests.push(command)
const override = this.requestOverride?.(command)
@@ -52,7 +55,7 @@ export class FakeConnection implements PiRpcConnection {
return override
}
if (command === 'get_state') {
return state
return { ...state, ...this.reported }
}
if (command === 'get_available_models') {
return { models: [state.model] }
@@ -121,7 +124,8 @@ export function reportedEvent<T extends PiLifecycleEvent['type']>(
export async function setup(
options: Readonly<Record<string, string>> = {},
eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink
eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink,
reported?: ConstructorParameters<typeof FakeConnection>[1]
) {
const rig = await openProviderTimelineRig({ agent: 'pi', sessionId })
const connections: FakeConnection[] = []
@@ -152,7 +156,7 @@ export async function setup(
}))
const openConnection = vi.fn(
(_launch: ProviderProcessLaunch, handlers: JsonlRpcAgentConnectionOptions) => {
const connection = new FakeConnection(handlers)
const connection = new FakeConnection(handlers, reported)
connections.push(connection)
return connection
}
+37
View File
@@ -200,6 +200,43 @@ describe('Pi RPC session ownership and delivery', () => {
expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }])
})
it('sends model-only choices away from the opening model, back, and away again', async () => {
const h = await setup({}, undefined, { thinkingLevel: 'medium' })
h.connection.requestOverride = (command) =>
command === 'set_thinking_level' ? Promise.resolve({}) : undefined
for (const [clientMessageId, model] of [
['away', 'anthropic/model-2'],
['back', 'anthropic/model-1'],
['again', 'anthropic/model-2']
]) {
await expect(
h.adapter.dispatch({
sessionId,
fence: 7,
clientMessageId,
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: clientMessageId }]
},
executionOptions: { model }
})
).resolves.toEqual({ state: 'admitted' })
}
// Back on the opening model, its opening effort is restored; elsewhere Pi keeps its own.
expect(h.connection.requests.filter((command) => command.startsWith('set_'))).toEqual([
'set_model',
'set_model',
'set_thinking_level',
'set_model'
])
expect(h.connection.sent).toMatchObject([
{ message: 'away' },
{ message: 'back' },
{ message: 'again' }
])
})
it('rejects a known-unsent prompt when an option times out and preserves Stop reason', async () => {
const h = await setup()
const timeout = new Error('Pi did not answer set_model')