From 31833e77086cadc3fddfb908415edfbf460448e6 Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Fri, 16 Oct 2026 10:09:00 +0200 Subject: [PATCH] feat: prepare agent runtime readiness and expose public defaults --- docs/agent-conversations.md | 26 +++++++- manifests/codex/.codex-plugin/plugin.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/shared/actions.ts | 2 +- src/shared/agentReadiness.ts | 66 +++++++++++++++++++ src/shared/agents.ts | 62 ++++++++++++++---- tests/agents.test.cjs | 77 +++++++++++++++++++++-- 8 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 src/shared/agentReadiness.ts diff --git a/docs/agent-conversations.md b/docs/agent-conversations.md index 62cca34..a4588ce 100644 --- a/docs/agent-conversations.md +++ b/docs/agent-conversations.md @@ -1,6 +1,6 @@ # Shared platform agent conversations -The additive 0.2.5 API discovers agents and exchanges messages through the existing +The additive 0.2.6 API discovers agents and exchanges messages through the existing MainSequence `/mcp` gateway. No backend routes, schemas, account handling, or provider configuration change. Claude UI, native draft/command handling and conversation controls belong to `mainsequence-claude-desktop`; this package contains only the reusable @@ -17,6 +17,7 @@ identity returned by discovery as `expectedAccount`. | `agents-list` | `agent.list`, or semantic `agent.search` | Optional search, limit and offset | | `agent-get` | `agent.get` | agentUid; optional expectedAccount | | `agent-session` | `agent.get_or_create_session` | agentUid, expectedAccount; exactly one of handleId or sessionUid | +| `agent-readiness` | `agent.get_or_create_session`, `agent_session.resolve_runtime_access` | agentUid, sessionUid, expectedAccount; optional timeoutMs (1–630000) | | `agent-send` | `a2a.send_message` | agentUid, expectedAccount, message, responseKind; exactly one of handleId, sessionUid, taskUid | | `agent-task-get` | `a2a.get_task` | agentUid, expectedAccount, taskUid | | `agent-task-wait` | `a2a.get_task`, `a2a.wait_task` | Same; optional afterSequence | @@ -40,6 +41,24 @@ UID and handle. Agent, environment and returned identity are checked. Missing provider/model defaults are reported without changing them. Client chat IDs are external handles, not MainSequence parent-session foreign keys. +`agent-readiness` validates the saved session and waits for +`runtime_interaction.can_submit`, independently of deployment status or `is_ready`. +Transient states follow the backend retry interval (100 ms minimum) within one +630-second cap, shortened by an earlier backend wake deadline plus 30 seconds. +Cancellation interrupts the wait; terminal or malformed decisions fail closed. +Progress goes through the host's progress callback as fixed credential-free text. +The public result is only `{account, agentUid, sessionUid, ready: true}`. The raw +runtime access response (token, URL, diagnostics and other fields) is never exposed +or persisted. This can wake the deployed agent but sends no message or inference. +Readiness is not cached across operations or restored as permanent state. + +Set `waitForReady: true` on `agent-send` to use the same readiness gate immediately +before dispatch, including task continuation. A readiness failure has no uncertain +message effect; a failure after dispatch retains the existing uncertain-send marker. +This option defaults off for compatibility with existing adapters, whose gateway +sender continues to own readiness. Claude 0.4.5 opts in. No UI package, Python SDK +or direct runtime HTTP transport is added. + A send may use that resolved `sessionUid`, or a stable handle for legacy clients. The returned `sessionUid` identifies follow-ups. Message mode is synchronous. Task mode always requests `completion_policy: poll`; `resume_caller` is for platform AgentSessions and is not used by desktop hosts. @@ -79,3 +98,8 @@ platform tool deadlines. Claude 0.4.4 consumes this additive API. Release throug the existing trusted-publisher workflow, verify the public archive integrity, and then pin that archive in the consumer. Existing Codex marketplace and VS Code pins are unchanged by this shared-library release. + +Version 0.2.6 adds readiness preparation and opt-in send-time revalidation for Claude +0.4.5. Existing session allocation and other adapter behavior remain unchanged. + +Agent list, search and detail results include `defaults: { provider, model, thinking }`, projected only from existing `llm_provider`, `llm_model` and `llm_thinking` fields. Empty or absent fields remain empty strings. These are Agent defaults for new sessions, not a claim about a resumed session’s resolved configuration. Runtime configuration and credential fields are excluded. diff --git a/manifests/codex/.codex-plugin/plugin.json b/manifests/codex/.codex-plugin/plugin.json index 3de4dfe..e44cefb 100644 --- a/manifests/codex/.codex-plugin/plugin.json +++ b/manifests/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mainsequence-devtools", - "version": "0.2.5", + "version": "0.2.6", "description": "Use MainSequence project MCPs from conversation and run explicit developer commands with the bundled runner.", "author": { "name": "MainSequence" diff --git a/package-lock.json b/package-lock.json index c94a1dc..7ee8e88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.2.5", + "version": "0.2.6", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index ee3b79f..23ed51b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.2.5", + "version": "0.2.6", "description": "Shared MainSequence developer actions, project operations and deterministic Codex adapter", "license": "MIT", "engines": { diff --git a/src/shared/actions.ts b/src/shared/actions.ts index d50f6f1..03f46ea 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -17,7 +17,7 @@ export function createDevTools(options: ConstructorParameters; + signal?: AbortSignal; + progress?: (message: string) => void; + timeoutMs?: number; +}): Promise { + const budget = options.timeoutMs ?? 630000; + if (!Number.isFinite(budget) || budget < 1 || budget > 630000) { + throw new ToolingError('INVALID_REQUEST', 'Agent readiness timeout must be between 1 and 630000 milliseconds.'); + } + let deadline = performance.now() + budget; + let lastProgress = ''; + const progress = (message: string) => { + if (message !== lastProgress) { lastProgress = message; options.progress?.(message); } + }; + const timedOut = () => new ToolingError('PROCESS_TIMEOUT', 'The agent did not become ready in time. No message was sent. Try again.'); + progress('Checking agent…'); + while (true) { + options.signal?.throwIfAborted(); + if (performance.now() >= deadline) throw timedOut(); + const access = await options.resolve(deadline) as any; + options.signal?.throwIfAborted(); + if (performance.now() >= deadline) throw timedOut(); + const interaction = access?.runtime_interaction; + if (!interaction || typeof interaction.can_submit !== 'boolean' || typeof interaction.state !== 'string') { + throw new ToolingError('API_ERROR', 'The platform did not provide a valid agent readiness decision. No message was sent.'); + } + if (interaction.can_submit) { + if (!['ready', 'warning'].includes(interaction.state) || access.mode !== 'token' + || typeof access.token !== 'string' || !access.token || typeof access.rpc_url !== 'string' || !access.rpc_url) { + throw new ToolingError('API_ERROR', 'The platform returned an incomplete agent readiness decision. No message was sent.'); + } + progress('Agent ready'); + return; + } + if (!['checking', 'starting', 'waking', 'updating'].includes(interaction.state)) { + const message = ({ update_required: 'The agent needs an update before it can accept messages.', + update_failed: 'The agent update failed.', unavailable: 'The agent is unavailable.' } as Record)[interaction.state] + ?? 'The agent cannot accept messages right now.'; + throw new ToolingError('API_ERROR', `${message} No message was sent. Try again when it is available.`); + } + const retry = interaction.retry_after_ms; + if (typeof retry !== 'number' || !Number.isFinite(retry) || retry < 0) { + throw new ToolingError('API_ERROR', 'The platform did not provide a valid readiness retry interval. No message was sent.'); + } + const wakeDeadline = access.runtime_presence?.wake?.deadline_at; + if (typeof wakeDeadline === 'string' && Number.isFinite(Date.parse(wakeDeadline))) { + deadline = Math.min(deadline, performance.now() + Math.max(0, Date.parse(wakeDeadline) + 30000 - Date.now())); + } + progress(interaction.state === 'updating' ? 'Agent update in progress…' + : interaction.state === 'checking' ? 'Checking agent…' : 'Starting agent…'); + const delay = Math.max(100, retry), remaining = deadline - performance.now(); + if (remaining <= 0) throw timedOut(); + await new Promise((resolve, reject) => { + const finish = () => { options.signal?.removeEventListener('abort', abort); resolve(); }; + const timer = setTimeout(finish, Math.min(delay, remaining)); + const abort = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); reject(new ToolingError('CANCELLED', 'Agent preparation cancelled. No message was sent.')); }; + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + }); + if (delay >= remaining) throw timedOut(); + } +} diff --git a/src/shared/agents.ts b/src/shared/agents.ts index a578ea8..74d7be8 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -2,23 +2,26 @@ import { randomUUID } from 'node:crypto'; import { ActionResult, SessionService, ToolingError } from './models'; import { requireBackend } from './session'; import { McpTransport } from './mcpTransport'; +import { waitForAgentReadiness } from './agentReadiness'; type AgentSelection = { backend?: string; environmentUid: string; expectedAccount?: string }; export type AgentRequest = AgentSelection & ( | { action: 'agents-list'; search?: string; limit?: number; offset?: number } | { action: 'agent-get'; agentUid: string } | { action: 'agent-session'; agentUid: string; handleId?: string; sessionUid?: string } + | { action: 'agent-readiness'; agentUid: string; sessionUid: string; timeoutMs?: number } | { action: 'agent-send'; agentUid: string; message: string; responseKind: 'message' | 'task'; - handleId?: string; sessionUid?: string; taskUid?: string } + handleId?: string; sessionUid?: string; taskUid?: string; waitForReady?: boolean } | { action: 'agent-task-get' | 'agent-task-wait' | 'agent-task-cancel'; agentUid: string; taskUid: string; afterSequence?: number } ); export type PlatformAgent = { uid: string; name: string; description: string; environmentUid: string; environmentName: string; repositoryBranch: string; runtimeReleaseUid: string | null; runtimeState: string; responseKinds: string[]; + defaults: { provider: string; model: string; thinking: string }; skills: { name: string; description: string; examples: string[] }[]; }; export function isAgentAction(action: string): boolean { - return ['agents-list', 'agent-get', 'agent-session', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(action); + return ['agents-list', 'agent-get', 'agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(action); } function uuid(value: unknown): string { if (typeof value !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value)) { @@ -34,16 +37,18 @@ function agent(row: any, environmentUid: string): PlatformAgent { environmentName: text(row.organization_environment_name, 300), repositoryBranch: text(row.repository_branch, 300), runtimeReleaseUid: row.runtime_release_uid ? uuid(row.runtime_release_uid) : null, runtimeState: text(row.runtime_update?.state, 50) || 'unknown', + defaults: { provider: text(row.llm_provider, 64).trim(), model: text(row.llm_model, 128).trim(), thinking: text(row.llm_thinking, 1000).trim() }, responseKinds: Array.isArray(row.a2a_profile?.supported_response_kinds) ? row.a2a_profile.supported_response_kinds.filter((value: unknown) => value === 'message' || value === 'task') : [], skills: (Array.isArray(row.agent_card?.skills) ? row.agent_card.skills : []).slice(0, 50).map((skill: any) => ({ name: text(skill.name, 300), description: text(skill.description), examples: (Array.isArray(skill.examples) ? skill.examples : []).slice(0, 10).map((value: unknown) => text(value, 2000)) })) }; } /** Host-independent platform Agent client. Never connects to a card's URL or exposes runtime credentials. */ export class AgentClient { - constructor(private readonly session: SessionService, private readonly options: { signal?: AbortSignal } = {}) {} + constructor(private readonly session: SessionService, private readonly options: { signal?: AbortSignal; progress?: (message: string) => void } = {}) {} async dispatch(input: AgentRequest, selectedBackend: string): Promise { let sent = false; + let readinessDeadline: number | undefined; try { const backend = requireBackend(selectedBackend), environmentUid = uuid(input.environmentUid); this.options.signal?.throwIfAborted(); @@ -51,13 +56,18 @@ export class AgentClient { const identity = await this.session.status(backend, true); if (!identity.authenticated || !identity.username) throw new ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to use agents.'); if (input.expectedAccount !== undefined && input.expectedAccount !== identity.username) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed. Refresh agents and start a new conversation.'); - if (['agent-session', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) { + if (['agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) { throw new ToolingError('INVALID_REQUEST', 'Agent conversations must be bound to the account returned by discovery.'); } const timeouts = new Map(); - const client = new McpTransport(message => this.session.request(backend, 'POST', '/mcp', message, + const client = new McpTransport(message => { + const remaining = readinessDeadline === undefined ? undefined : Math.ceil(readinessDeadline - performance.now()); + if (remaining !== undefined && remaining <= 0) throw new ToolingError('PROCESS_TIMEOUT', 'The agent did not become ready in time. No message was sent. Try again.'); + return this.session.request(backend, 'POST', '/mcp', message, message.method !== 'tools/call' || !['a2a.send_message', 'a2a.cancel_task'].includes((message.params as any)?.name), - message.method === 'tools/call' && timeouts.has((message.params as any)?.name) ? { timeoutMs: timeouts.get((message.params as any).name) } : undefined)); + remaining !== undefined ? { timeoutMs: Math.min(30000, remaining) } + : message.method === 'tools/call' && timeouts.has((message.params as any)?.name) ? { timeoutMs: timeouts.get((message.params as any).name) } : undefined); + }); await client.initialize(); const tools = await client.tools(); const catalog = new Set(tools.map(tool => tool.name)); @@ -100,7 +110,33 @@ export class AgentClient { if (uuid(result.agent_uid) !== agentUid || uuid(result.task_uid) !== uuid(taskUid)) throw new ToolingError('INVALID_REQUEST', 'This task belongs to another agent.'); return result; }; + const resolveSession = async (target: Record) => { + const resolved = await call('agent.get_or_create_session', { agent_uid: agentUid, ...target }); + if (uuid(resolved.agent_uid) !== agentUid || uuid(resolved.organization_environment_uid) !== environmentUid + || (target.session_uid && uuid(resolved.uid) !== uuid(target.session_uid)) + || (target.handle_unique_id && resolved.bound_handle?.handle_unique_id !== target.handle_unique_id)) { + throw new ToolingError('API_ERROR', 'The platform returned another agent conversation.'); + } + return resolved; + }; + const ready = async (sessionUid: string, timeoutMs?: number) => { + if (!selected.runtimeReleaseUid || selected.runtimeState === 'not_deployed') throw new ToolingError('UNSUPPORTED_ACTION', 'This agent has no deployed runtime.'); + try { + await waitForAgentReadiness({ signal: this.options.signal, progress: this.options.progress, timeoutMs, + resolve: async deadline => { + readinessDeadline = deadline; + const account = await this.session.status(backend, true); + if (!account.authenticated || account.username !== identity.username) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed while checking the agent. Choose the agent again.'); + return call('agent_session.resolve_runtime_access', { agent_session_uid: sessionUid }); + } }); + } finally { readinessDeadline = undefined; } + }; if (input.action === 'agent-get') data = { account: identity.username, agent: selected }; + else if (input.action === 'agent-readiness') { + const resolved = await resolveSession({ session_uid: uuid(input.sessionUid) }); + await ready(uuid(resolved.uid), input.timeoutMs); + data = { account: identity.username, agentUid, sessionUid: uuid(resolved.uid), ready: true }; + } else if (input.action === 'agent-session') { if ([input.handleId, input.sessionUid].filter(value => value !== undefined).length !== 1) { throw new ToolingError('INVALID_REQUEST', 'Use exactly one conversation handle or session.'); @@ -113,12 +149,7 @@ export class AgentClient { } target = { handle_unique_id: input.handleId }; } - const resolved = await call('agent.get_or_create_session', { agent_uid: agentUid, ...target }); - if (uuid(resolved.agent_uid) !== agentUid || uuid(resolved.organization_environment_uid) !== environmentUid - || (input.sessionUid !== undefined && uuid(resolved.uid) !== uuid(input.sessionUid)) - || (input.handleId && resolved.bound_handle?.handle_unique_id !== input.handleId)) { - throw new ToolingError('API_ERROR', 'The platform returned another agent conversation.'); - } + const resolved = await resolveSession(target); data = { account: identity.username, agent: selected, sessionUid: uuid(resolved.uid), ...(resolved.bound_handle?.handle_unique_id ? { handleId: text(resolved.bound_handle.handle_unique_id, 255) } : {}) }; } @@ -136,6 +167,13 @@ export class AgentClient { if (typeof input.handleId !== 'string' || !/^[-a-zA-Z0-9_]{1,255}$/.test(input.handleId)) throw new ToolingError('INVALID_REQUEST', 'A stable conversation handle is required.'); target = { agent_uid: agentUid, handle_unique_id: input.handleId }; } + if (input.waitForReady !== undefined && typeof input.waitForReady !== 'boolean') throw new ToolingError('INVALID_REQUEST', 'waitForReady must be a boolean.'); + if (input.waitForReady) { + const resolved = await resolveSession(input.taskUid + ? { session_uid: uuid((await task(input.taskUid)).agent_session_uid) } + : input.sessionUid ? { session_uid: uuid(input.sessionUid) } : { handle_unique_id: input.handleId }); + await ready(uuid(resolved.uid)); + } const reply = await call('a2a.send_message', { ...target, message: input.message, message_id: randomUUID(), response_kind: input.responseKind, ...(input.responseKind === 'task' ? { completion_policy: 'poll' } : {}) }, true); if (uuid(reply.agent_uid) !== agentUid || reply.response_kind !== input.responseKind || (input.sessionUid && uuid(reply.agent_session_uid) !== uuid(input.sessionUid))) throw new ToolingError('API_ERROR', 'The agent response does not match this conversation.'); diff --git a/tests/agents.test.cjs b/tests/agents.test.cjs index b1e8b15..3c973cd 100644 --- a/tests/agents.test.cjs +++ b/tests/agents.test.cjs @@ -6,9 +6,9 @@ const backend = 'https://platform.example', environmentUid = id(1), agentUid = i const row = () => ({ uid: agentUid, name: 'Analyst', description: 'Analyze risk', organization_environment_uid: environmentUid, organization_environment_name: 'Research', repository_branch: 'develop', runtime_release_uid: id(5), runtime_update: {state:'current'}, a2a_profile: {supported_response_kinds:['message','task']}, agent_card: {url:'https://never-contact.example/',skills:[{name:'Risk',description:'Explain exposure',examples:['Explain risk']} ]}, - configuration: {secret:'not-public'}, runtime_config: {token:'not-public'} }); -function fixture() { - const calls = [], state = {account:'person@example.test',row:row(),catalog:['agent.list','agent.search','agent.get','agent.get_or_create_session','a2a.send_message','a2a.get_task','a2a.wait_task','a2a.cancel_task']}; + llm_provider:'anthropic', llm_model:'claude-sonnet-test', llm_thinking:'high', configuration: {secret:'not-public'}, runtime_config: {token:'not-public'} }); +function fixture(options = {}) { + const calls = [], state = {account:'person@example.test',row:row(),catalog:['agent.list','agent.search','agent.get','agent.get_or_create_session','agent_session.resolve_runtime_access','a2a.send_message','a2a.get_task','a2a.wait_task','a2a.cancel_task']}; const task = () => ({agent_uid:agentUid,agent_session_uid:sessionUid,task_uid:taskUid,status:'working',terminal:false,event_cursor:2,outputs:[],cancellation_requested:false}); const session = {status:async (url,check)=>{assert.equal(url,backend);assert.equal(check,true);return {authenticated:true,username:state.account}},sanitize:s=>s.replaceAll('known-secret','[redacted]'), request:async(url,method,path,body,retrySafe,options)=>{ @@ -22,6 +22,10 @@ function fixture() { if(name==='agent.search')data={results:[state.row]}; if(name==='agent.get')data=state.row; if(name==='agent.get_or_create_session')data={uid:args.session_uid||sessionUid,agent_uid:agentUid,organization_environment_uid:environmentUid,bound_handle:{handle_unique_id:args.handle_unique_id},runtime_token:'not-public',...(state.sessionOverride||{})}; + if(name==='agent_session.resolve_runtime_access') { + data=state.accessQueue?.length ? state.accessQueue.shift() : {mode:'token',token:'private-runtime-token',rpc_url:'https://private-runtime.invalid',runtime_interaction:{state:'ready',can_submit:true}}; + state.afterAccess?.(); + } if(name==='a2a.get_task')data={...task(),...(state.taskOverride||{})}; if(name==='a2a.wait_task')data={...task(),events:[],timed_out:true}; if(name==='a2a.cancel_task')data={...task(),cancellation_requested:true}; @@ -32,7 +36,7 @@ function fixture() { } return reply({structuredContent:data,isError:false}); }}; - const api=createDevTools({host:createNodeHost(),session,env:{}}); + const api=createDevTools({host:createNodeHost(options.progress),session,env:{},signal:options.signal}); return {calls,state,send:extra=>api.dispatch({action:'agent-send',backend,environmentUid,agentUid,expectedAccount:'person@example.test',message:'What do you do?',responseKind:'message',handleId:'claude_conversation_1',...extra}), dispatch:(action,extra={})=>api.dispatch({action,backend,environmentUid,...extra})}; } @@ -114,3 +118,68 @@ test('advertised tool deadlines are bounded and invalid values cannot shorten ag assert.equal(f.calls.find(c=>c.body.params?.name==='a2a.send_message').options.timeoutMs,expected); } }); + +const readinessSelection={agentUid,sessionUid,expectedAccount:'person@example.test'}; +const waking=(retry=1)=>({is_ready:true,mode:'unavailable',token:null,runtime_interaction:{state:'waking',can_submit:false,retry_after_ms:retry},runtime_presence:{detail:'private-runtime-token https://private-runtime.invalid'}}); +test('readiness waits for admission, ignoring routability, and exposes no access credentials or URL',async()=>{ + const progress=[],f=fixture({progress:s=>progress.push(s)});f.state.accessQueue=[waking()]; + const result=await f.dispatch('agent-readiness',readinessSelection); + assert.equal(result.status,'success');assert.deepEqual(result.data,{account:'person@example.test',agentUid,sessionUid,ready:true}); + assert.deepEqual(progress,['Checking agent…','Starting agent…','Agent ready']); + assert.equal(f.calls.filter(c=>c.body.params?.name==='agent_session.resolve_runtime_access').length,2); + assert.equal(f.calls.some(c=>c.body.params?.name==='a2a.send_message'),false); + assert.doesNotMatch(JSON.stringify([result,progress]),/private-runtime|rpc_url|token/); +}); +test('readiness fails closed for missing, inconsistent and terminal decisions without sending',async()=>{ + for(const access of [{is_ready:true}, {runtime_interaction:{state:'ready',can_submit:'true'}}, + {runtime_interaction:{state:'ready',can_submit:true},mode:'unavailable'},waking(NaN), + {runtime_interaction:{state:'unavailable',can_submit:false,retry_after_ms:1},detail:'private-runtime-token'}]) { + const f=fixture();f.state.accessQueue=[access];const result=await f.dispatch('agent-readiness',readinessSelection); + assert.equal(result.status,'failure');assert.equal(result.effects,undefined);assert.doesNotMatch(JSON.stringify(result),/private-runtime/); + assert.equal(f.calls.filter(c=>c.body.params?.name==='agent_session.resolve_runtime_access').length,1); + } +}); +test('readiness respects its wait budget and never polls earlier than a long server retry',async()=>{ + const f=fixture();f.state.accessQueue=[waking(60000)]; + const result=await f.dispatch('agent-readiness',{...readinessSelection,timeoutMs:10}); + assert.equal(result.error.code,'PROCESS_TIMEOUT');assert.equal(result.effects,undefined); + assert.equal(f.calls.filter(c=>c.body.params?.name==='agent_session.resolve_runtime_access').length,1); + for(const timeoutMs of [0,-1,Infinity,630001,'5']) assert.equal((await f.dispatch('agent-readiness',{...readinessSelection,timeoutMs})).error.code,'INVALID_REQUEST'); +}); +test('cancellation ends an in-progress wake without polling or sending',async()=>{ + const controller=new AbortController(),f=fixture({signal:controller.signal,progress:s=>{if(s==='Starting agent…')controller.abort();}}); + f.state.accessQueue=[waking(60000)];const result=await f.dispatch('agent-readiness',readinessSelection); + assert.equal(result.status,'cancelled');assert.equal(result.effects,undefined); + assert.equal(f.calls.filter(c=>c.body.params?.name==='agent_session.resolve_runtime_access').length,1); +}); +test('resumed sessions revalidate identity and account changes stop a readiness wait',async()=>{ + const f=fixture();await f.dispatch('agent-readiness',readinessSelection);await f.dispatch('agent-readiness',readinessSelection); + assert.equal(f.calls.filter(c=>c.body.params?.name==='agent_session.resolve_runtime_access').length,2); + f.state.accessQueue=[waking()];f.state.afterAccess=()=>{f.state.account='changed'}; + assert.equal((await f.dispatch('agent-readiness',readinessSelection)).error.code,'AUTH_REJECTED'); + const mismatch=fixture();mismatch.state.sessionOverride={agent_uid:id(99)}; + assert.equal((await mismatch.dispatch('agent-readiness',readinessSelection)).status,'failure'); + assert.equal(mismatch.calls.some(c=>c.body.params?.name==='agent_session.resolve_runtime_access'),false); +}); +test('opt-in send readiness stops known-unsent failures and dispatches exactly once when ready',async()=>{ + const f=fixture();f.state.accessQueue=[waking()];assert.equal((await f.send({waitForReady:true})).status,'success'); + const names=f.calls.map(c=>c.body.params?.name);assert.equal(names.filter(n=>n==='a2a.send_message').length,1); + assert.ok(names.lastIndexOf('agent_session.resolve_runtime_access')c.body.params?.name==='a2a.send_message'),false); + const uncertain=fixture();uncertain.state.failSend=true;assert.equal((await uncertain.send({waitForReady:true})).effects.state,'partial-or-uncertain'); + assert.equal(uncertain.calls.filter(c=>c.body.params?.name==='a2a.send_message').length,1); +}); + +test('agent defaults are a read-only allowlisted projection across list, search and detail',async()=>{ + const f=fixture(); + for(const [action,extra] of [['agents-list',{}],['agents-list',{search:'risk'}],['agent-get',{agentUid}]]) { + const result=await f.dispatch(action,extra),agent=result.data.agent||result.data.results[0]; + assert.deepEqual(agent.defaults,{provider:'anthropic',model:'claude-sonnet-test',thinking:'high'}); + assert.doesNotMatch(JSON.stringify(result),/not-public/); + } + f.state.row.llm_provider='';delete f.state.row.llm_model;f.state.row.llm_thinking={token:'not-public'}; + assert.deepEqual((await f.dispatch('agents-list')).data.results[0].defaults,{provider:'',model:'',thinking:''}); + assert.equal(f.calls.some(c=>c.body.params?.name==='a2a.send_message'),false); +});