diff --git a/AGUI.md b/A2UI.md similarity index 93% rename from AGUI.md rename to A2UI.md index 5b7573f..6ac9db7 100644 --- a/AGUI.md +++ b/A2UI.md @@ -1,4 +1,4 @@ -# AGUI Modernization Plan (Protocol-First) +# A2UI Modernization Plan (Protocol-First) ## Purpose @@ -56,7 +56,7 @@ Build a **protocol-first chat assistant** that: ### Already implemented -- AGUI schema parsing for canonical `specVersion: "1"` payloads. +- A2UI schema parsing for canonical `specVersion: "1"` payloads. - Rich widget rendering (chart, form, input, datePicker, card, image, tabs). - Shared controls renderer reused by both chat surfaces. - Text + UI mixed response extraction. @@ -71,7 +71,7 @@ Build a **protocol-first chat assistant** that: - No explicit protocol envelope enforced server-side each turn. - No formal capability handshake/version negotiation. - No repair/retry orchestration as a first-class protocol step. -- No end-to-end telemetry contract for AGUI reliability metrics. +- No end-to-end telemetry contract for A2UI reliability metrics. --- @@ -82,7 +82,7 @@ Build a **protocol-first chat assistant** that: - Missing: strict response envelope (`assistant_text`, `ui`, `intent`, `confidence`, `needs_input`). - Missing: request envelope (`messages`, `context`, `capabilities`, `surface`, `protocol_version`). - Missing: version negotiation and deprecation strategy. -- Missing: canonical machine-readable error model for invalid AGUI payloads. +- Missing: canonical machine-readable error model for invalid A2UI payloads. ## 2) Model Interaction Strategy @@ -119,13 +119,13 @@ Build a **protocol-first chat assistant** that: ## 7) Test Architecture - Missing: protocol conformance suite (golden request/response cases). -- Missing: end-to-end AGUI scenario tests (clarify + execute + reflect). +- Missing: end-to-end A2UI scenario tests (clarify + execute + reflect). - Missing: fuzz tests for malformed payload handling. - Missing: migration tests for protocol version compatibility. ## 8) Governance and Docs -- Missing: authoritative AGUI protocol spec doc with examples. +- Missing: authoritative A2UI protocol spec doc with examples. - Missing: widget/action compatibility matrix by version. - Missing: internal governance for protocol changes and ownership boundaries. @@ -135,7 +135,7 @@ Build a **protocol-first chat assistant** that: ## A. Core Components -1. **AGUI Protocol Layer (Main Process)** +1. **A2UI Protocol Layer (Main Process)** - Owns request/response envelopes, validation, normalization, repair loop. 2. **Capability Registry Service (Main Process)** @@ -147,7 +147,7 @@ Build a **protocol-first chat assistant** that: 4. **Action Runtime (Renderer + Main IPC)** - Executes declared actions with policy checks and structured result events. -5. **AGUI Renderer (Renderer Shared)** +5. **A2UI Renderer (Renderer Shared)** - Renders protocol `ui` payloads with strict schema and graceful fallbacks. 6. **Observability Pipeline (Main + Renderer)** @@ -228,7 +228,7 @@ Reason invalid: ### Protocol Error Codes -- `AGUI_PROTOCOL_VALIDATION_ERROR` +- `A2UI_PROTOCOL_VALIDATION_ERROR` - Emitted for request/response envelope validation failures. - Includes human-readable `message` and per-field `details`. @@ -240,7 +240,7 @@ Reason invalid: ### Scope -- Add AGUI protocol specification section in repo docs. +- Add A2UI protocol specification section in repo docs. - Introduce canonical request/response TypeScript contracts. - Add protocol validator module in main process. @@ -248,7 +248,7 @@ Reason invalid: - `src/main/agentic/protocol/types.ts` - `src/main/agentic/protocol/validator.ts` -- `AGUI.md` + protocol examples + error codes +- `A2UI.md` + protocol examples + error codes ### Acceptance Criteria @@ -323,7 +323,7 @@ Reason invalid: ### Deliverables - Action policy map. -- Confirmation UI flow integrated into AGUI actions. +- Confirmation UI flow integrated into A2UI actions. - Action audit log entries with trace IDs. ### Acceptance Criteria @@ -336,14 +336,14 @@ Reason invalid: ### Scope - Instrument protocol metrics and error taxonomy. -- Build conformance + E2E AGUI test suites. +- Build conformance + E2E A2UI test suites. - Add internal test gates that block merges on protocol drift. ### Deliverables - Protocol metrics dashboard. - Golden test fixtures for representative workflows. -- CI quality gates for protocol conformance and AGUI scenarios. +- CI quality gates for protocol conformance and A2UI scenarios. ### Acceptance Criteria @@ -381,7 +381,7 @@ Reason invalid: ## Documentation -- Add AGUI protocol appendix with canonical and invalid examples. +- Add A2UI protocol appendix with canonical and invalid examples. - Add migration guide from legacy message parsing to v2 envelope. --- @@ -391,7 +391,7 @@ Reason invalid: The branch is complete when: 1. **Protocol reliability** - - ≥ 98% of AGUI-intent turns produce valid envelope without renderer fallback. + - ≥ 98% of A2UI-intent turns produce valid envelope without renderer fallback. 2. **UI execution reliability** - ≥ 95% of emitted actions execute successfully or fail with structured actionable error. @@ -400,7 +400,7 @@ The branch is complete when: - Missing-input tasks use `needsInput` controls instead of textual back-and-forth in ≥ 90% of cases. 4. **Cross-surface parity** - - Same AGUI payload renders and behaves equivalently in chat tab and sidebar. + - Same A2UI payload renders and behaves equivalently in chat tab and sidebar. 5. **Governance and maintainability** - Protocol conformance suite and migration tests are mandatory in CI. diff --git a/API.md b/API.md index b5ab8ac..5a4d18d 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ # API Documentation -Contract version: 1.4.0 +Contract version: 1.5.0 This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide. @@ -3518,10 +3518,12 @@ Send message to chat conversation. - conversationId (str, required) - message (str, required) +- metadata (dict, optional) **Response specification** -- Return type: `{ success: boolean; message?: string; error?: string }` +- Return type: `{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }` +- Data structures: `ProtocolResponseEnvelope` **Example call** @@ -3533,7 +3535,18 @@ result = await bds.chat.send_message(conversation_id='conversation-1', message=' **Example response** ```python -{} +[ +{ + 'protocolVersion': None, + 'assistantText': 'value', + 'ui': [], + 'intent': None, + 'needsInput': False, + 'actions': [], + 'confidence': 0, + 'traceId': 'value' +} +] ``` ### chat.abortMessage @@ -4085,6 +4098,54 @@ Stored API key state for chat provider. [↑ Back to Table of contents](#table-of-contents) +### ProtocolNeedsInputField + +A required clarification input field used for needsInput prompts. + +**Fields** + +- key (`string`, required): Stable field key used in submitted values. +- label (`string`, required): User-facing field label. +- inputType (`'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number'`, required): Rendered input control type. +- required (`boolean`, optional): Whether user input is required. +- options (`Array<{ label: string; value: string }>`, optional): Selectable options for select controls. +- placeholder (`string`, optional): Optional placeholder text for text-like controls. +- defaultValue (`string | number | boolean`, optional): Default field value shown in UI. + +[↑ Back to Table of contents](#table-of-contents) + +### ProtocolAction + +A declarative assistant action exposed to the UI runtime. + +**Fields** + +- id (`string`, required): Stable action id within a response envelope. +- action (`string`, required): Action name to dispatch in renderer. +- label (`string`, optional): Optional user-facing action label. +- payload (`Record`, optional): Optional action payload arguments. +- policy (`'silent' | 'confirm' | 'danger'`, required): Action confirmation policy level. +- requiresConfirmation (`boolean`, required): Whether confirmation is required before dispatch. + +[↑ Back to Table of contents](#table-of-contents) + +### ProtocolResponseEnvelope + +Canonical AGUI response envelope returned from chat.sendMessage. + +**Fields** + +- protocolVersion (`'2.0'`, required): Envelope protocol version. +- assistantText (`string`, required): Assistant text content rendered in transcript. +- ui (`{ specVersion: '1'; elements: unknown[] }`, optional): Optional structured UI payload. +- intent (`'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'`, required): Turn intent classification. +- needsInput (`{ required: boolean; fields: ProtocolNeedsInputField[] }`, required): Clarification requirements for next step. +- actions (`ProtocolAction[]`, required): Declarative actions available for this turn. +- confidence (`number`, required): Model confidence score from 0 to 1. +- traceId (`string`, required): Trace id for observability and debugging. + +[↑ Back to Table of contents](#table-of-contents) + ### ProtocolTelemetrySnapshot Aggregated protocol telemetry metrics for AGUI response health. diff --git a/src/main/agentic/protocol/responseBuilder.ts b/src/main/agentic/protocol/responseBuilder.ts index 4c12a6c..ad598b1 100644 --- a/src/main/agentic/protocol/responseBuilder.ts +++ b/src/main/agentic/protocol/responseBuilder.ts @@ -7,7 +7,8 @@ import type { ProtocolValidationError, } from './types'; import { validateProtocolResponseEnvelope } from './validator'; -import { extractAssistantUiSpec } from './uiSpecParser'; +import { extractAssistantUiSpec, normalizeAssistantUiSpec } from './uiSpecParser'; +import { assistantPanelSpecSchema } from './uiSchema'; import { resolveActionPolicy } from '../policy/actionPolicy'; export interface ProtocolResponseBuildInput { @@ -30,7 +31,8 @@ export class ProtocolResponseBuilder { const directEnvelope = this.parseCanonicalEnvelope(input.rawAssistantOutput); if (directEnvelope) { - const normalizedDirectEnvelope = this.applyActionPolicies(directEnvelope); + const sanitizedDirectEnvelope = this.sanitizeUiPayload(directEnvelope, warnings); + const normalizedDirectEnvelope = this.applyActionPolicies(sanitizedDirectEnvelope); const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedDirectEnvelope, input.capabilities); warnings.push(...capabilityWarnings); const validated = validateProtocolResponseEnvelope(filteredEnvelope); @@ -55,7 +57,8 @@ export class ProtocolResponseBuilder { const repaired = this.repairRawEnvelope(input.rawAssistantOutput); if (repaired) { - const normalizedRepairedEnvelope = this.applyActionPolicies(repaired); + const sanitizedRepairedEnvelope = this.sanitizeUiPayload(repaired, warnings); + const normalizedRepairedEnvelope = this.applyActionPolicies(sanitizedRepairedEnvelope); const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedRepairedEnvelope, input.capabilities); warnings.push(...capabilityWarnings); const validated = validateProtocolResponseEnvelope(filteredEnvelope); @@ -88,7 +91,8 @@ export class ProtocolResponseBuilder { traceId: randomUUID(), }; - const normalizedBaseEnvelope = this.applyActionPolicies(baseEnvelope); + const sanitizedBaseEnvelope = this.sanitizeUiPayload(baseEnvelope, warnings); + const normalizedBaseEnvelope = this.applyActionPolicies(sanitizedBaseEnvelope); const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedBaseEnvelope, input.capabilities); warnings.push(...capabilityWarnings); @@ -112,9 +116,48 @@ export class ProtocolResponseBuilder { }; } + private sanitizeUiPayload(envelope: ProtocolResponseEnvelope, warnings: string[]): ProtocolResponseEnvelope { + if (!envelope.ui) { + return envelope; + } + + const parsedUi = assistantPanelSpecSchema.safeParse(envelope.ui); + if (parsedUi.success) { + return { + ...envelope, + ui: parsedUi.data, + }; + } + + const normalizedUi = normalizeAssistantUiSpec(envelope.ui); + if (normalizedUi) { + warnings.push('Normalized non-canonical ui payload to canonical AGUI schema'); + return { + ...envelope, + ui: normalizedUi, + }; + } + + warnings.push('Invalid ui payload removed from response envelope'); + return { + ...envelope, + ui: undefined, + }; + } + + private extractJsonFromMarkdown(raw: string): string { + const trimmed = raw.trim(); + const match = trimmed.match(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/i); + if (match) { + return match[1].trim(); + } + return trimmed; + } + private parseCanonicalEnvelope(raw: string): ProtocolResponseEnvelope | null { try { - const parsed = JSON.parse(raw); + const jsonString = this.extractJsonFromMarkdown(raw); + const parsed = JSON.parse(jsonString); const validated = validateProtocolResponseEnvelope(parsed); return validated.ok && validated.value ? validated.value : null; } catch { @@ -124,14 +167,16 @@ export class ProtocolResponseBuilder { private repairRawEnvelope(raw: string): ProtocolResponseEnvelope | null { try { - const parsed = JSON.parse(raw) as Record; + const jsonString = this.extractJsonFromMarkdown(raw); + const parsed = JSON.parse(jsonString) as Record; const looksLikeEnvelope = Boolean( parsed.assistantText || parsed.assistant_text || parsed.intent || parsed.needsInput || parsed.needs_input - || parsed.actions, + || parsed.actions + || parsed.ui, ); if (!looksLikeEnvelope) { diff --git a/src/main/agentic/protocol/uiSpecParser.ts b/src/main/agentic/protocol/uiSpecParser.ts index 9f054a6..773a772 100644 --- a/src/main/agentic/protocol/uiSpecParser.ts +++ b/src/main/agentic/protocol/uiSpecParser.ts @@ -9,15 +9,39 @@ function toRecord(value: unknown): Record | null { } function normalizeChartElement(record: Record): Record { + const chartType = record.chartType; const normalized: Record = { - ...record, + type: 'chart', + chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar', }; - const dataRecord = toRecord(record.data); - if (Array.isArray(record.series)) { - return normalized; + if (typeof record.title === 'string' && record.title.trim().length > 0) { + normalized.title = record.title; } + if (Array.isArray(record.series)) { + const series = record.series + .map((entry) => { + const item = toRecord(entry); + if (!item || typeof item.label !== 'string' || typeof item.value !== 'number') { + return null; + } + + return { + label: item.label, + value: item.value, + }; + }) + .filter((entry): entry is { label: string; value: number } => Boolean(entry)); + + if (series.length > 0) { + normalized.series = series; + return normalized; + } + } + + const dataRecord = toRecord(record.data); + if (!dataRecord) { return normalized; } @@ -43,7 +67,6 @@ function normalizeChartElement(record: Record): Record | null { } const type = typeof record.type === 'string' ? record.type : ''; + if (type === 'text' && typeof record.content === 'string' && typeof record.text !== 'string') { + return { type: 'text', text: record.content }; + } + if (type === 'markdown') { const textValue = typeof record.content === 'string' ? record.content @@ -145,6 +172,10 @@ function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null { return null; } + if (record.protocolVersion === '2.0' && record.ui) { + return normalizeCandidate(record.ui); + } + if (record.type === 'tab' && record.content) { return normalizeCandidate(record.content); } @@ -202,6 +233,10 @@ function parseSpecCandidate(raw: string): AssistantPanelSpec | null { } } +export function normalizeAssistantUiSpec(input: unknown): AssistantPanelSpec | null { + return normalizeCandidate(input); +} + export interface ParsedAssistantUiResult { assistantText: string; ui: AssistantPanelSpec | null; @@ -210,9 +245,9 @@ export interface ParsedAssistantUiResult { export function extractAssistantUiSpec(message: string): ParsedAssistantUiResult { const trimmed = message.trim(); - const fencedMatches = [...trimmed.matchAll(/```(json)?\s*([\s\S]*?)```/gi)]; + const fencedMatches = [...trimmed.matchAll(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/gi)]; for (const match of fencedMatches) { - const candidate = match[2]?.trim(); + const candidate = match[1]?.trim(); if (!candidate) { continue; } diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index fc52638..c5afeb1 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -332,7 +332,7 @@ Agentic UI Contract: - You may include structured UI payloads in your assistant response so the app can render interactive widgets. - You DO have the ability to return interactive AGUI payloads (including bar charts) as JSON, even though you cannot draw bitmap images. - When the user asks for a chart or guided workflow, prefer returning a valid AGUI payload over refusing. -- Use JSON with specVersion: "1" and an elements array. +- Place the AGUI payload in the "ui" field of the protocol response envelope. DO NOT output markdown code blocks containing JSON. - Prefer actionable widgets (cards, forms, tabs, inputs, metrics, tables, charts) when they reduce follow-up friction. - Keep textual guidance and UI semantically consistent. - Include only valid, supported action names. Supported actions include: openSettings, openPost, openMedia, openPanel, setActiveView, toggleSidebar, togglePanel, toggleAssistantSidebar. diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 5a6f149..86b70a3 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -18,7 +18,7 @@ import { MediaEngine } from './MediaEngine'; import { getPostMediaEngine } from './PostMediaEngine'; import { ProtocolResponseBuilder } from '../agentic/protocol/responseBuilder'; import { CapabilityRegistryService } from '../agentic/capabilities/registry'; -import { validateProtocolRequestEnvelope } from '../agentic/protocol/validator'; +import { validateProtocolRequestEnvelope, validateProtocolResponseEnvelope } from '../agentic/protocol/validator'; import type { ProtocolResponseEnvelope } from '../agentic/protocol/types'; import { AgentTurnStateMachine, type AgentTurnState } from '../agentic/workflow/turnStateMachine'; import { WorkflowCheckpointStore } from '../agentic/workflow/checkpointStore'; @@ -149,6 +149,15 @@ export class OpenCodeManager { private apiKey: string = ''; private abortControllers: Map = new Map(); + private readonly protocolBoundaryInstructions = `Protocol response requirements (strict): +- Return a single JSON object that matches this exact envelope schema: + {"protocolVersion":"2.0","assistantText":"string","ui":{"specVersion":"1","elements":[]}?,"intent":"analyze|ask_input|propose_action|execute_action|summarize","needsInput":{"required":boolean,"fields":[]},"actions":[],"confidence":number,"traceId":"string"} +- Do not return any top-level shape other than this envelope. +- Do not use legacy top-level keys like title/widgets/tabs/content/data/widgets. +- ui, if present, must use specVersion "1" and canonical element structures only. +- DO NOT output markdown code blocks containing JSON. The entire response must be the JSON envelope. +- If uncertain, return an envelope with assistantText and empty actions/ui rather than alternative JSON formats.`; + constructor( chatEngine: ChatEngine, postEngine: PostEngine, @@ -294,6 +303,7 @@ export class OpenCodeManager { // Get system prompt const systemMessage = conversation.messages.find(m => m.role === 'system'); const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); + const protocolSystemPrompt = `${systemPrompt}\n\n${this.protocolBoundaryInstructions}`; // Build message history from DB (excluding system messages) const dbMessages = conversation.messages.filter(m => m.role !== 'system'); @@ -339,29 +349,34 @@ export class OpenCodeManager { let fullResponse = ''; const toolCallsCollected: Array<{ name: string; args: unknown }> = []; + const requestProvider = async ( + prompt: string, + messages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>, + ) => { + if (provider === 'anthropic') { + return this.sendAnthropicMessage( + modelId, + prompt, + messages, + abortController.signal, + { onDelta, onToolCall, onToolResult }, + ); + } + + return this.sendOpenAIMessage( + modelId, + prompt, + messages, + abortController.signal, + { onDelta, onToolCall, onToolResult }, + ); + }; + try { console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId); - if (provider === 'anthropic') { - const result = await this.sendAnthropicMessage( - modelId, - systemPrompt, - dbMessages, - abortController.signal, - { onDelta, onToolCall, onToolResult } - ); - fullResponse = result.content; - toolCallsCollected.push(...result.toolCalls); - } else { - const result = await this.sendOpenAIMessage( - modelId, - systemPrompt, - dbMessages, - abortController.signal, - { onDelta, onToolCall, onToolResult } - ); - fullResponse = result.content; - toolCallsCollected.push(...result.toolCalls); - } + const firstResult = await requestProvider(protocolSystemPrompt, dbMessages); + fullResponse = firstResult.content; + toolCallsCollected.push(...firstResult.toolCalls); console.log('[OpenCodeManager] fullResponse length:', fullResponse.length); } catch (error) { console.error('[OpenCodeManager] Request error:', (error as Error).message); @@ -374,23 +389,55 @@ export class OpenCodeManager { this.abortControllers.delete(conversationId); } - // Save assistant response (including partial content from aborted requests) - if (fullResponse) { - await this.chatEngine.addMessage({ - conversationId, - role: 'assistant', - content: fullResponse, - toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined, - createdAt: new Date(), - }); - } + const isCanonicalProtocolEnvelope = (() => { + try { + const parsed = JSON.parse(fullResponse); + const validated = validateProtocolResponseEnvelope(parsed); + return validated.ok; + } catch { + return false; + } + })(); - const protocolResult = this.protocolResponseBuilder.build({ + let protocolResult = this.protocolResponseBuilder.build({ rawAssistantOutput: fullResponse, surface, capabilities, }); + if (!isCanonicalProtocolEnvelope && fullResponse.trim().length > 0 && !abortController.signal.aborted) { + const retryReason = protocolResult.validationError?.message || 'previous output was not a canonical protocol envelope'; + const retryPrompt = `Your previous output failed protocol validation: ${retryReason}.\nReturn ONLY one valid protocol envelope JSON object and nothing else.`; + const retryMessages = [ + ...dbMessages, + { + conversationId, + role: 'assistant', + content: fullResponse, + createdAt: new Date(), + }, + { + conversationId, + role: 'user', + content: retryPrompt, + createdAt: new Date(), + }, + ]; + + try { + const retryResult = await requestProvider(protocolSystemPrompt, retryMessages); + fullResponse = retryResult.content; + toolCallsCollected.push(...retryResult.toolCalls); + protocolResult = this.protocolResponseBuilder.build({ + rawAssistantOutput: fullResponse, + surface, + capabilities, + }); + } catch (error) { + console.error('[OpenCodeManager] Protocol retry failed:', (error as Error).message); + } + } + const previousCheckpoint = await this.workflowCheckpointStore.load(conversationId); const previousState: AgentTurnState = previousCheckpoint?.state || 'planning'; const nextState = this.turnStateMachine.transition({ @@ -417,6 +464,17 @@ export class OpenCodeManager { blockedActions: blockedActionWarnings.length, }); + // Save normalized assistant response to history so transcript does not render raw protocol JSON. + if (fullResponse) { + await this.chatEngine.addMessage({ + conversationId, + role: 'assistant', + content: protocolResult.envelope.assistantText, + toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined, + createdAt: new Date(), + }); + } + // Generate title after first exchange const userMsgCount = conversation.messages.filter(m => m.role === 'user').length; if (userMsgCount === 0 && fullResponse) { diff --git a/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx b/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx index 30e22b9..b1f2b1a 100644 --- a/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx +++ b/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx @@ -5,6 +5,7 @@ import { planAssistantRequest } from '../../navigation/assistantConversation'; import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher'; import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec'; import { toClarificationElements } from '../../navigation/protocolNeedsInput'; +import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies'; import { ensureConversationId } from '../../navigation/chatSession'; import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode'; import { useChatMessageSender } from '../../navigation/useChatMessageSender'; @@ -54,6 +55,7 @@ export const AssistantSidebar: React.FC = () => { finalizeAssistantTurn, appendAssistantMessage, stopStreaming, + getStreamingContent, } = useChatSurfaceState(); const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]); @@ -173,20 +175,18 @@ export const AssistantSidebar: React.FC = () => { ? (sendResult.envelope.ui?.elements as AssistantPanelElement[]) : toClarificationElements(sendResult.envelope.needsInput); setPanelElements(uiElements); - setActionPolicies( - sendResult.envelope.actions.reduce>((accumulator, action) => { - accumulator[action.action] = action.policy; - return accumulator; - }, {}), - ); - } else if (sendResult.message) { - const parsedResponse = extractAssistantResponseContent(sendResult.message); - finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText); - setPanelElements(parsedResponse.panelSpec?.elements ?? []); - setActionPolicies({}); + setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope)); } else { - appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse')); - stopStreaming(); + const assistantContent = getStreamingContent() || sendResult.message; + if (assistantContent) { + const parsedResponse = extractAssistantResponseContent(assistantContent); + finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText); + setPanelElements(parsedResponse.panelSpec?.elements ?? []); + setActionPolicies({}); + } else { + appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse')); + stopStreaming(); + } } setPrompt(''); @@ -230,17 +230,13 @@ export const AssistantSidebar: React.FC = () => { ? (sendResult.envelope.ui?.elements as AssistantPanelElement[]) : toClarificationElements(sendResult.envelope.needsInput); setPanelElements(uiElements); - setActionPolicies( - sendResult.envelope.actions.reduce>((accumulator, action) => { - accumulator[action.action] = action.policy; - return accumulator; - }, {}), - ); + setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope)); return; } - if (sendResult.message) { - const parsedResponse = extractAssistantResponseContent(sendResult.message); + const assistantContent = getStreamingContent() || sendResult.message; + if (assistantContent) { + const parsedResponse = extractAssistantResponseContent(assistantContent); finalizeAssistantTurn(conversationId, parsedResponse.displayText); setPanelElements(parsedResponse.panelSpec?.elements ?? []); setActionPolicies({}); diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index d442704..6fbdde8 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -6,6 +6,7 @@ import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode'; import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher'; import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec'; import { toClarificationElements } from '../../navigation/protocolNeedsInput'; +import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies'; import { useAppStore } from '../../store'; import { ChatTranscript } from '../ChatSurface'; import { AssistantPanelControls } from '../AssistantPanelControls'; @@ -198,12 +199,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { ? (result.envelope.ui?.elements as AssistantPanelElement[]) : toClarificationElements(result.envelope.needsInput); setPanelElements(uiElements); - setActionPolicies( - result.envelope.actions.reduce>((accumulator, action) => { - accumulator[action.action] = action.policy; - return accumulator; - }, {}), - ); + setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope)); } else if (assistantContent) { const parsedResponse = extractAssistantResponseContent(assistantContent); finalizeAssistantTurn(conversationId, parsedResponse.displayText); @@ -272,12 +268,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { ? (result.envelope.ui?.elements as AssistantPanelElement[]) : toClarificationElements(result.envelope.needsInput); setPanelElements(uiElements); - setActionPolicies( - result.envelope.actions.reduce>((accumulator, action) => { - accumulator[action.action] = action.policy; - return accumulator; - }, {}), - ); + setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope)); return; } diff --git a/src/renderer/navigation/assistantPanelSpec.ts b/src/renderer/navigation/assistantPanelSpec.ts index d959dec..0fcdb07 100644 --- a/src/renderer/navigation/assistantPanelSpec.ts +++ b/src/renderer/navigation/assistantPanelSpec.ts @@ -170,15 +170,35 @@ function toRecord(value: unknown): Record | null { } function normalizeChartElement(record: Record): Record | null { + const chartType = record.chartType; const normalized: Record = { - ...record, + type: 'chart', + chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar', }; - const dataRecord = toRecord(record.data); - if (Array.isArray(record.series)) { - return normalized; + if (typeof record.title === 'string' && record.title.trim().length > 0) { + normalized.title = record.title; } + if (Array.isArray(record.series)) { + const series = record.series + .map((entry) => { + const item = toRecord(entry); + if (!item || typeof item.label !== 'string' || typeof item.value !== 'number') { + return null; + } + return { label: item.label, value: item.value }; + }) + .filter((entry): entry is { label: string; value: number } => Boolean(entry)); + + if (series.length > 0) { + normalized.series = series; + return normalized; + } + } + + const dataRecord = toRecord(record.data); + if (!dataRecord) { return normalized; } @@ -204,7 +224,6 @@ function normalizeChartElement(record: Record): Record | null { } const type = typeof record.type === 'string' ? record.type : ''; + if (type === 'text' && typeof record.content === 'string' && typeof record.text !== 'string') { + return { type: 'text', text: record.content }; + } + if (type === 'markdown') { const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : ''; if (!textValue.trim()) { @@ -302,6 +325,10 @@ function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null { return null; } + if (record.protocolVersion === '2.0' && record.ui) { + return normalizeCandidate(record.ui); + } + if (record.type === 'tab' && record.content) { return normalizeCandidate(record.content); } @@ -366,9 +393,9 @@ export function extractAssistantPanelSpec(message: string): AssistantPanelSpec | export function extractAssistantResponseContent(message: string): AssistantResponseContent { const trimmed = message.trim(); - const fencedMatches = [...trimmed.matchAll(/```(json)?\s*([\s\S]*?)```/gi)]; + const fencedMatches = [...trimmed.matchAll(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/gi)]; for (const match of fencedMatches) { - const candidate = match[2]?.trim(); + const candidate = match[1]?.trim(); if (!candidate) { continue; } @@ -384,8 +411,21 @@ export function extractAssistantResponseContent(message: string): AssistantRespo } const parsedWholeMessage = parseSpecCandidate(trimmed); + let displayText = parsedWholeMessage ? '' : trimmed; + + if (parsedWholeMessage) { + try { + const parsed = JSON.parse(trimmed) as Record; + if (parsed.protocolVersion === '2.0' && typeof parsed.assistantText === 'string') { + displayText = parsed.assistantText; + } + } catch { + // no-op + } + } + return { - displayText: parsedWholeMessage ? '' : trimmed, + displayText, panelSpec: parsedWholeMessage, }; } diff --git a/src/renderer/navigation/protocolActionPolicies.ts b/src/renderer/navigation/protocolActionPolicies.ts new file mode 100644 index 0000000..e7f7e01 --- /dev/null +++ b/src/renderer/navigation/protocolActionPolicies.ts @@ -0,0 +1,18 @@ +import type { ProtocolResponseEnvelope } from '../types/electron'; + +export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger'; + +export function buildActionPoliciesFromEnvelope( + envelope: Pick, +): Record { + const policies = envelope.actions.reduce>((accumulator, action) => { + accumulator[action.action] = action.policy; + return accumulator; + }, {}); + + if (envelope.needsInput.required && envelope.needsInput.fields.length > 0 && !policies.submitNeedsInput) { + policies.submitNeedsInput = 'confirm'; + } + + return policies; +} diff --git a/src/renderer/python/pythonApiContractV1.ts b/src/renderer/python/pythonApiContractV1.ts index fdf3111..25fdf2d 100644 --- a/src/renderer/python/pythonApiContractV1.ts +++ b/src/renderer/python/pythonApiContractV1.ts @@ -186,7 +186,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [ method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'), method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'), method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'), - method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message')], '{ success: boolean; message?: string; error?: string }'), + method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], "{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }"), method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'), method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'), method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'), @@ -360,6 +360,45 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ { name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' }, ], }, + { + name: 'ProtocolNeedsInputField', + description: 'A required clarification input field used for needsInput prompts.', + fields: [ + { name: 'key', type: 'string', required: true, description: 'Stable field key used in submitted values.' }, + { name: 'label', type: 'string', required: true, description: 'User-facing field label.' }, + { name: 'inputType', type: "'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number'", required: true, description: 'Rendered input control type.' }, + { name: 'required', type: 'boolean', required: false, description: 'Whether user input is required.' }, + { name: 'options', type: 'Array<{ label: string; value: string }>', required: false, description: 'Selectable options for select controls.' }, + { name: 'placeholder', type: 'string', required: false, description: 'Optional placeholder text for text-like controls.' }, + { name: 'defaultValue', type: 'string | number | boolean', required: false, description: 'Default field value shown in UI.' }, + ], + }, + { + name: 'ProtocolAction', + description: 'A declarative assistant action exposed to the UI runtime.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Stable action id within a response envelope.' }, + { name: 'action', type: 'string', required: true, description: 'Action name to dispatch in renderer.' }, + { name: 'label', type: 'string', required: false, description: 'Optional user-facing action label.' }, + { name: 'payload', type: 'Record', required: false, description: 'Optional action payload arguments.' }, + { name: 'policy', type: "'silent' | 'confirm' | 'danger'", required: true, description: 'Action confirmation policy level.' }, + { name: 'requiresConfirmation', type: 'boolean', required: true, description: 'Whether confirmation is required before dispatch.' }, + ], + }, + { + name: 'ProtocolResponseEnvelope', + description: 'Canonical AGUI response envelope returned from chat.sendMessage.', + fields: [ + { name: 'protocolVersion', type: "'2.0'", required: true, description: 'Envelope protocol version.' }, + { name: 'assistantText', type: 'string', required: true, description: 'Assistant text content rendered in transcript.' }, + { name: 'ui', type: "{ specVersion: '1'; elements: unknown[] }", required: false, description: 'Optional structured UI payload.' }, + { name: 'intent', type: "'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'", required: true, description: 'Turn intent classification.' }, + { name: 'needsInput', type: '{ required: boolean; fields: ProtocolNeedsInputField[] }', required: true, description: 'Clarification requirements for next step.' }, + { name: 'actions', type: 'ProtocolAction[]', required: true, description: 'Declarative actions available for this turn.' }, + { name: 'confidence', type: 'number', required: true, description: 'Model confidence score from 0 to 1.' }, + { name: 'traceId', type: 'string', required: true, description: 'Trace id for observability and debugging.' }, + ], + }, { name: 'ProtocolTelemetrySnapshot', description: 'Aggregated protocol telemetry metrics for AGUI response health.', @@ -377,7 +416,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ ]; export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = { - version: '1.4.0', + version: '1.5.0', generatedAt: '2026-02-25T00:00:00.000Z', methods: METHODS_V1, dataStructures: DATA_STRUCTURES_V1, diff --git a/tests/engine/OpenCodeManager.protocol.test.ts b/tests/engine/OpenCodeManager.protocol.test.ts index 9642ca4..766e68d 100644 --- a/tests/engine/OpenCodeManager.protocol.test.ts +++ b/tests/engine/OpenCodeManager.protocol.test.ts @@ -97,6 +97,12 @@ describe('OpenCodeManager protocol integration', () => { const telemetryAfter = getProtocolTelemetryService().getSnapshot(); expect(telemetryAfter.totalTurns).toBe(telemetryBefore.totalTurns + 1); expect(telemetryAfter.validEnvelopeTurns).toBe(telemetryBefore.validEnvelopeTurns + 1); + + expect(chatEngineMock.addMessage).toHaveBeenCalledWith(expect.objectContaining({ + conversationId: 'conversation-1', + role: 'assistant', + content: 'Please provide a date range.', + })); }); it('blocks unsupported actions and records blocked-action telemetry', async () => { @@ -149,4 +155,71 @@ describe('OpenCodeManager protocol integration', () => { const telemetryAfter = getProtocolTelemetryService().getSnapshot(); expect(telemetryAfter.blockedActionCount).toBe(telemetryBefore.blockedActionCount + 1); }); + + it('retries once with protocol repair prompt when first output is non-canonical', async () => { + const conversation: MockConversation = { + id: 'conversation-3', + model: 'gpt-5', + messages: [{ role: 'user', content: 'show chart' }], + }; + + const chatEngineMock = createChatEngineMock(conversation); + const manager = new OpenCodeManager( + chatEngineMock as never, + {} as never, + {} as never, + () => null, + ); + manager.setApiKey('test-api-key'); + + const sendSpy = vi.spyOn(manager as never, 'sendOpenAIMessage') + .mockResolvedValueOnce({ + content: JSON.stringify({ + title: 'Legacy JSON', + widgets: [{ type: 'chart', chartType: 'bar' }], + }), + toolCalls: [], + }) + .mockResolvedValueOnce({ + content: JSON.stringify({ + protocolVersion: '2.0', + assistantText: 'Here is your chart.', + ui: { + specVersion: '1', + elements: [ + { + type: 'chart', + chartType: 'bar', + series: [{ label: '2015', value: 86 }], + }, + ], + }, + intent: 'summarize', + needsInput: { required: false, fields: [] }, + actions: [], + confidence: 0.8, + traceId: 'trace-retry-success', + }), + toolCalls: [], + }); + + const result = await manager.sendMessage('conversation-3', 'Build chart', { + metadata: { surface: 'tab' }, + }); + + expect(result.success).toBe(true); + expect(result.envelope?.traceId).toBe('trace-retry-success'); + expect(sendSpy).toHaveBeenCalledTimes(2); + + const retryMessages = sendSpy.mock.calls[1]?.[2] as Array<{ role: string; content?: string }>; + const lastMessage = retryMessages[retryMessages.length - 1]?.content ?? ''; + expect(lastMessage).toContain('failed protocol validation'); + expect(lastMessage).toContain('Return ONLY one valid protocol envelope JSON object'); + + expect(chatEngineMock.addMessage).toHaveBeenCalledWith(expect.objectContaining({ + conversationId: 'conversation-3', + role: 'assistant', + content: 'Here is your chart.', + })); + }); }); \ No newline at end of file diff --git a/tests/engine/agentic/protocol/responseBuilder.test.ts b/tests/engine/agentic/protocol/responseBuilder.test.ts index 286782b..09875a7 100644 --- a/tests/engine/agentic/protocol/responseBuilder.test.ts +++ b/tests/engine/agentic/protocol/responseBuilder.test.ts @@ -171,4 +171,92 @@ describe('ProtocolResponseBuilder', () => { requiresConfirmation: false, })); }); + + it('drops invalid ui payloads from canonical envelopes before renderer consumption', () => { + const builder = new ProtocolResponseBuilder(); + + const raw = JSON.stringify({ + protocolVersion: '2.0', + assistantText: 'Here is the result', + intent: 'summarize', + needsInput: { required: false, fields: [] }, + actions: [], + ui: { + specVersion: '1', + elements: [ + { + type: 'chart', + chartType: 'bar', + }, + ], + }, + confidence: 0.7, + traceId: 'trace-invalid-ui', + }); + + const result = builder.build({ + rawAssistantOutput: raw, + surface: 'tab', + capabilities: { + widgets: ['chart'], + actions: ['openPost'], + tools: ['search_posts'], + }, + }); + + expect(result.envelope.ui).toBeUndefined(); + expect(result.warnings.some((warning) => warning.includes('Invalid ui payload'))).toBe(true); + }); + + it('normalizes non-canonical ui element fields inside canonical envelopes', () => { + const builder = new ProtocolResponseBuilder(); + + const raw = JSON.stringify({ + protocolVersion: '2.0', + assistantText: 'Distribution chart ready.', + ui: { + specVersion: '1', + elements: [ + { + type: 'chart', + chartType: 'bar', + data: { + labels: ['aside', 'article'], + datasets: [{ data: [181, 53] }], + }, + }, + { + type: 'text', + content: 'Category breakdown', + }, + ], + }, + intent: 'summarize', + needsInput: { required: false, fields: [] }, + actions: [], + confidence: 0.95, + traceId: 'trace-normalize-ui', + }); + + const result = builder.build({ + rawAssistantOutput: raw, + surface: 'tab', + capabilities: { + widgets: ['chart', 'text'], + actions: ['openPost'], + tools: ['search_posts'], + }, + }); + + const elements = result.envelope.ui?.elements as Array<{ type: string; series?: Array<{ label: string; value: number }>; text?: string }>; + expect(elements).toHaveLength(2); + expect(elements[0]?.type).toBe('chart'); + expect(elements[0]?.series).toEqual([ + { label: 'aside', value: 181 }, + { label: 'article', value: 53 }, + ]); + expect(elements[1]).toEqual({ type: 'text', text: 'Category breakdown' }); + expect(result.warnings.some((warning) => warning.includes('Normalized non-canonical ui payload'))).toBe(true); + }); + }); diff --git a/tests/renderer/navigation/assistantPanelSpec.test.ts b/tests/renderer/navigation/assistantPanelSpec.test.ts index 5e239e8..e892ab6 100644 --- a/tests/renderer/navigation/assistantPanelSpec.test.ts +++ b/tests/renderer/navigation/assistantPanelSpec.test.ts @@ -192,4 +192,49 @@ describe('assistantPanelSpec', () => { expect(result).not.toBeNull(); expect(result?.elements).toHaveLength(7); }); + + it('parses canonical protocol envelope JSON and extracts assistant text plus ui spec', () => { + const raw = JSON.stringify({ + protocolVersion: '2.0', + assistantText: 'Here is your chart.', + ui: { + specVersion: '1', + elements: [ + { + type: 'chart', + chartType: 'bar', + data: { + labels: ['aside', 'article'], + datasets: [{ data: [181, 53] }], + }, + }, + { + type: 'text', + content: 'Breakdown details', + }, + ], + }, + intent: 'summarize', + needsInput: { required: false, fields: [] }, + actions: [], + confidence: 0.9, + traceId: 'trace-1', + }); + + const result = extractAssistantResponseContent(raw); + + expect(result.displayText).toBe('Here is your chart.'); + expect(result.panelSpec).not.toBeNull(); + expect(result.panelSpec?.elements[0]).toMatchObject({ + type: 'chart', + series: [ + { label: 'aside', value: 181 }, + { label: 'article', value: 53 }, + ], + }); + expect(result.panelSpec?.elements[1]).toEqual({ + type: 'text', + text: 'Breakdown details', + }); + }); }); diff --git a/tests/renderer/navigation/protocolActionPolicies.test.ts b/tests/renderer/navigation/protocolActionPolicies.test.ts new file mode 100644 index 0000000..f659874 --- /dev/null +++ b/tests/renderer/navigation/protocolActionPolicies.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { buildActionPoliciesFromEnvelope } from '../../../src/renderer/navigation/protocolActionPolicies'; + +describe('buildActionPoliciesFromEnvelope', () => { + it('preserves server-provided action policies', () => { + const result = buildActionPoliciesFromEnvelope({ + actions: [ + { + id: 'a1', + action: 'openSettings', + policy: 'confirm', + requiresConfirmation: true, + }, + ], + needsInput: { + required: false, + fields: [], + }, + }); + + expect(result).toEqual({ + openSettings: 'confirm', + }); + }); + + it('adds confirm policy for submitNeedsInput when clarification is required', () => { + const result = buildActionPoliciesFromEnvelope({ + actions: [], + needsInput: { + required: true, + fields: [{ key: 'date', label: 'Date', inputType: 'date' }], + }, + }); + + expect(result.submitNeedsInput).toBe('confirm'); + }); + + it('does not override explicit server policy for submitNeedsInput', () => { + const result = buildActionPoliciesFromEnvelope({ + actions: [ + { + id: 'a1', + action: 'submitNeedsInput', + policy: 'danger', + requiresConfirmation: true, + }, + ], + needsInput: { + required: true, + fields: [{ key: 'title', label: 'Title', inputType: 'text' }], + }, + }); + + expect(result.submitNeedsInput).toBe('danger'); + }); +}); diff --git a/tests/renderer/python/pythonApiContractV1.test.ts b/tests/renderer/python/pythonApiContractV1.test.ts index b5eee7e..d90a3ec 100644 --- a/tests/renderer/python/pythonApiContractV1.test.ts +++ b/tests/renderer/python/pythonApiContractV1.test.ts @@ -44,9 +44,34 @@ describe('pythonApiContractV1', () => { }); }); + it('documents chat.sendMessage protocol envelope return contract and metadata input', () => { + expect(getPythonApiMethodContract('chat.sendMessage')).toEqual({ + method: 'chat.sendMessage', + description: 'Send message to chat conversation.', + params: [ + { + name: 'conversationId', + type: 'string', + required: true, + }, + { + name: 'message', + type: 'string', + required: true, + }, + { + name: 'metadata', + type: 'object', + required: false, + }, + ], + returns: "{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }", + }); + }); + it('contains semantic version metadata for compatibility checks', () => { expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({ - version: '1.4.0', + version: '1.5.0', generatedAt: expect.any(String), }); }); @@ -56,6 +81,7 @@ describe('pythonApiContractV1', () => { expect.objectContaining({ name: 'PostData' }), expect.objectContaining({ name: 'MediaData' }), expect.objectContaining({ name: 'ProjectData' }), + expect.objectContaining({ name: 'ProtocolResponseEnvelope' }), expect.objectContaining({ name: 'ProtocolTelemetrySnapshot' }), ])); }); @@ -76,7 +102,7 @@ describe('generatePythonApiModuleV1', () => { expect(moduleCode).toContain('async def search(self, query):'); expect(moduleCode).toContain('async def get_project_metadata(self):'); expect(moduleCode).toContain('async def get_conversations(self):'); - expect(moduleCode).toContain('async def send_message(self, conversation_id, message):'); + expect(moduleCode).toContain('async def send_message(self, conversation_id, message, metadata=None):'); expect(moduleCode).toContain('class BdsApi:'); expect(moduleCode).toContain('bds = BdsApi(_transport)'); });