wip: desparate models fucking around
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# AGUI Modernization Plan (Protocol-First)
|
# A2UI Modernization Plan (Protocol-First)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ Build a **protocol-first chat assistant** that:
|
|||||||
|
|
||||||
### Already implemented
|
### 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).
|
- Rich widget rendering (chart, form, input, datePicker, card, image, tabs).
|
||||||
- Shared controls renderer reused by both chat surfaces.
|
- Shared controls renderer reused by both chat surfaces.
|
||||||
- Text + UI mixed response extraction.
|
- 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 explicit protocol envelope enforced server-side each turn.
|
||||||
- No formal capability handshake/version negotiation.
|
- No formal capability handshake/version negotiation.
|
||||||
- No repair/retry orchestration as a first-class protocol step.
|
- 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: strict response envelope (`assistant_text`, `ui`, `intent`, `confidence`, `needs_input`).
|
||||||
- Missing: request envelope (`messages`, `context`, `capabilities`, `surface`, `protocol_version`).
|
- Missing: request envelope (`messages`, `context`, `capabilities`, `surface`, `protocol_version`).
|
||||||
- Missing: version negotiation and deprecation strategy.
|
- 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
|
## 2) Model Interaction Strategy
|
||||||
|
|
||||||
@@ -119,13 +119,13 @@ Build a **protocol-first chat assistant** that:
|
|||||||
## 7) Test Architecture
|
## 7) Test Architecture
|
||||||
|
|
||||||
- Missing: protocol conformance suite (golden request/response cases).
|
- 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: fuzz tests for malformed payload handling.
|
||||||
- Missing: migration tests for protocol version compatibility.
|
- Missing: migration tests for protocol version compatibility.
|
||||||
|
|
||||||
## 8) Governance and Docs
|
## 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: widget/action compatibility matrix by version.
|
||||||
- Missing: internal governance for protocol changes and ownership boundaries.
|
- Missing: internal governance for protocol changes and ownership boundaries.
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ Build a **protocol-first chat assistant** that:
|
|||||||
|
|
||||||
## A. Core Components
|
## A. Core Components
|
||||||
|
|
||||||
1. **AGUI Protocol Layer (Main Process)**
|
1. **A2UI Protocol Layer (Main Process)**
|
||||||
- Owns request/response envelopes, validation, normalization, repair loop.
|
- Owns request/response envelopes, validation, normalization, repair loop.
|
||||||
|
|
||||||
2. **Capability Registry Service (Main Process)**
|
2. **Capability Registry Service (Main Process)**
|
||||||
@@ -147,7 +147,7 @@ Build a **protocol-first chat assistant** that:
|
|||||||
4. **Action Runtime (Renderer + Main IPC)**
|
4. **Action Runtime (Renderer + Main IPC)**
|
||||||
- Executes declared actions with policy checks and structured result events.
|
- 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.
|
- Renders protocol `ui` payloads with strict schema and graceful fallbacks.
|
||||||
|
|
||||||
6. **Observability Pipeline (Main + Renderer)**
|
6. **Observability Pipeline (Main + Renderer)**
|
||||||
@@ -228,7 +228,7 @@ Reason invalid:
|
|||||||
|
|
||||||
### Protocol Error Codes
|
### Protocol Error Codes
|
||||||
|
|
||||||
- `AGUI_PROTOCOL_VALIDATION_ERROR`
|
- `A2UI_PROTOCOL_VALIDATION_ERROR`
|
||||||
- Emitted for request/response envelope validation failures.
|
- Emitted for request/response envelope validation failures.
|
||||||
- Includes human-readable `message` and per-field `details`.
|
- Includes human-readable `message` and per-field `details`.
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ Reason invalid:
|
|||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|
||||||
- Add AGUI protocol specification section in repo docs.
|
- Add A2UI protocol specification section in repo docs.
|
||||||
- Introduce canonical request/response TypeScript contracts.
|
- Introduce canonical request/response TypeScript contracts.
|
||||||
- Add protocol validator module in main process.
|
- Add protocol validator module in main process.
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ Reason invalid:
|
|||||||
|
|
||||||
- `src/main/agentic/protocol/types.ts`
|
- `src/main/agentic/protocol/types.ts`
|
||||||
- `src/main/agentic/protocol/validator.ts`
|
- `src/main/agentic/protocol/validator.ts`
|
||||||
- `AGUI.md` + protocol examples + error codes
|
- `A2UI.md` + protocol examples + error codes
|
||||||
|
|
||||||
### Acceptance Criteria
|
### Acceptance Criteria
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ Reason invalid:
|
|||||||
### Deliverables
|
### Deliverables
|
||||||
|
|
||||||
- Action policy map.
|
- Action policy map.
|
||||||
- Confirmation UI flow integrated into AGUI actions.
|
- Confirmation UI flow integrated into A2UI actions.
|
||||||
- Action audit log entries with trace IDs.
|
- Action audit log entries with trace IDs.
|
||||||
|
|
||||||
### Acceptance Criteria
|
### Acceptance Criteria
|
||||||
@@ -336,14 +336,14 @@ Reason invalid:
|
|||||||
### Scope
|
### Scope
|
||||||
|
|
||||||
- Instrument protocol metrics and error taxonomy.
|
- 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.
|
- Add internal test gates that block merges on protocol drift.
|
||||||
|
|
||||||
### Deliverables
|
### Deliverables
|
||||||
|
|
||||||
- Protocol metrics dashboard.
|
- Protocol metrics dashboard.
|
||||||
- Golden test fixtures for representative workflows.
|
- 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
|
### Acceptance Criteria
|
||||||
|
|
||||||
@@ -381,7 +381,7 @@ Reason invalid:
|
|||||||
|
|
||||||
## Documentation
|
## 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.
|
- Add migration guide from legacy message parsing to v2 envelope.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -391,7 +391,7 @@ Reason invalid:
|
|||||||
The branch is complete when:
|
The branch is complete when:
|
||||||
|
|
||||||
1. **Protocol reliability**
|
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**
|
2. **UI execution reliability**
|
||||||
- ≥ 95% of emitted actions execute successfully or fail with structured actionable error.
|
- ≥ 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.
|
- Missing-input tasks use `needsInput` controls instead of textual back-and-forth in ≥ 90% of cases.
|
||||||
|
|
||||||
4. **Cross-surface parity**
|
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**
|
5. **Governance and maintainability**
|
||||||
- Protocol conformance suite and migration tests are mandatory in CI.
|
- Protocol conformance suite and migration tests are mandatory in CI.
|
||||||
67
API.md
67
API.md
@@ -1,6 +1,6 @@
|
|||||||
# API Documentation
|
# 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.
|
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)
|
- conversationId (str, required)
|
||||||
- message (str, required)
|
- message (str, required)
|
||||||
|
- metadata (dict, optional)
|
||||||
|
|
||||||
**Response specification**
|
**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**
|
**Example call**
|
||||||
|
|
||||||
@@ -3533,7 +3535,18 @@ result = await bds.chat.send_message(conversation_id='conversation-1', message='
|
|||||||
**Example response**
|
**Example response**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
{}
|
[
|
||||||
|
{
|
||||||
|
'protocolVersion': None,
|
||||||
|
'assistantText': 'value',
|
||||||
|
'ui': [],
|
||||||
|
'intent': None,
|
||||||
|
'needsInput': False,
|
||||||
|
'actions': [],
|
||||||
|
'confidence': 0,
|
||||||
|
'traceId': 'value'
|
||||||
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### chat.abortMessage
|
### chat.abortMessage
|
||||||
@@ -4085,6 +4098,54 @@ Stored API key state for chat provider.
|
|||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
[↑ 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<string, unknown>`, 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
|
### ProtocolTelemetrySnapshot
|
||||||
|
|
||||||
Aggregated protocol telemetry metrics for AGUI response health.
|
Aggregated protocol telemetry metrics for AGUI response health.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
ProtocolValidationError,
|
ProtocolValidationError,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { validateProtocolResponseEnvelope } from './validator';
|
import { validateProtocolResponseEnvelope } from './validator';
|
||||||
import { extractAssistantUiSpec } from './uiSpecParser';
|
import { extractAssistantUiSpec, normalizeAssistantUiSpec } from './uiSpecParser';
|
||||||
|
import { assistantPanelSpecSchema } from './uiSchema';
|
||||||
import { resolveActionPolicy } from '../policy/actionPolicy';
|
import { resolveActionPolicy } from '../policy/actionPolicy';
|
||||||
|
|
||||||
export interface ProtocolResponseBuildInput {
|
export interface ProtocolResponseBuildInput {
|
||||||
@@ -30,7 +31,8 @@ export class ProtocolResponseBuilder {
|
|||||||
|
|
||||||
const directEnvelope = this.parseCanonicalEnvelope(input.rawAssistantOutput);
|
const directEnvelope = this.parseCanonicalEnvelope(input.rawAssistantOutput);
|
||||||
if (directEnvelope) {
|
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);
|
const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedDirectEnvelope, input.capabilities);
|
||||||
warnings.push(...capabilityWarnings);
|
warnings.push(...capabilityWarnings);
|
||||||
const validated = validateProtocolResponseEnvelope(filteredEnvelope);
|
const validated = validateProtocolResponseEnvelope(filteredEnvelope);
|
||||||
@@ -55,7 +57,8 @@ export class ProtocolResponseBuilder {
|
|||||||
|
|
||||||
const repaired = this.repairRawEnvelope(input.rawAssistantOutput);
|
const repaired = this.repairRawEnvelope(input.rawAssistantOutput);
|
||||||
if (repaired) {
|
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);
|
const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedRepairedEnvelope, input.capabilities);
|
||||||
warnings.push(...capabilityWarnings);
|
warnings.push(...capabilityWarnings);
|
||||||
const validated = validateProtocolResponseEnvelope(filteredEnvelope);
|
const validated = validateProtocolResponseEnvelope(filteredEnvelope);
|
||||||
@@ -88,7 +91,8 @@ export class ProtocolResponseBuilder {
|
|||||||
traceId: randomUUID(),
|
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);
|
const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedBaseEnvelope, input.capabilities);
|
||||||
warnings.push(...capabilityWarnings);
|
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 {
|
private parseCanonicalEnvelope(raw: string): ProtocolResponseEnvelope | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const jsonString = this.extractJsonFromMarkdown(raw);
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
const validated = validateProtocolResponseEnvelope(parsed);
|
const validated = validateProtocolResponseEnvelope(parsed);
|
||||||
return validated.ok && validated.value ? validated.value : null;
|
return validated.ok && validated.value ? validated.value : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -124,14 +167,16 @@ export class ProtocolResponseBuilder {
|
|||||||
|
|
||||||
private repairRawEnvelope(raw: string): ProtocolResponseEnvelope | null {
|
private repairRawEnvelope(raw: string): ProtocolResponseEnvelope | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
const jsonString = this.extractJsonFromMarkdown(raw);
|
||||||
|
const parsed = JSON.parse(jsonString) as Record<string, unknown>;
|
||||||
const looksLikeEnvelope = Boolean(
|
const looksLikeEnvelope = Boolean(
|
||||||
parsed.assistantText
|
parsed.assistantText
|
||||||
|| parsed.assistant_text
|
|| parsed.assistant_text
|
||||||
|| parsed.intent
|
|| parsed.intent
|
||||||
|| parsed.needsInput
|
|| parsed.needsInput
|
||||||
|| parsed.needs_input
|
|| parsed.needs_input
|
||||||
|| parsed.actions,
|
|| parsed.actions
|
||||||
|
|| parsed.ui,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!looksLikeEnvelope) {
|
if (!looksLikeEnvelope) {
|
||||||
|
|||||||
@@ -9,15 +9,39 @@ function toRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> {
|
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const chartType = record.chartType;
|
||||||
const normalized: Record<string, unknown> = {
|
const normalized: Record<string, unknown> = {
|
||||||
...record,
|
type: 'chart',
|
||||||
|
chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar',
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataRecord = toRecord(record.data);
|
if (typeof record.title === 'string' && record.title.trim().length > 0) {
|
||||||
if (Array.isArray(record.series)) {
|
normalized.title = record.title;
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!dataRecord) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -43,7 +67,6 @@ function normalizeChartElement(record: Record<string, unknown>): Record<string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalized.series = series;
|
normalized.series = series;
|
||||||
delete normalized.data;
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +129,10 @@ function normalizeElement(value: unknown): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const type = typeof record.type === 'string' ? record.type : '';
|
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') {
|
if (type === 'markdown') {
|
||||||
const textValue = typeof record.content === 'string'
|
const textValue = typeof record.content === 'string'
|
||||||
? record.content
|
? record.content
|
||||||
@@ -145,6 +172,10 @@ function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (record.protocolVersion === '2.0' && record.ui) {
|
||||||
|
return normalizeCandidate(record.ui);
|
||||||
|
}
|
||||||
|
|
||||||
if (record.type === 'tab' && record.content) {
|
if (record.type === 'tab' && record.content) {
|
||||||
return normalizeCandidate(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 {
|
export interface ParsedAssistantUiResult {
|
||||||
assistantText: string;
|
assistantText: string;
|
||||||
ui: AssistantPanelSpec | null;
|
ui: AssistantPanelSpec | null;
|
||||||
@@ -210,9 +245,9 @@ export interface ParsedAssistantUiResult {
|
|||||||
export function extractAssistantUiSpec(message: string): ParsedAssistantUiResult {
|
export function extractAssistantUiSpec(message: string): ParsedAssistantUiResult {
|
||||||
const trimmed = message.trim();
|
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) {
|
for (const match of fencedMatches) {
|
||||||
const candidate = match[2]?.trim();
|
const candidate = match[1]?.trim();
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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.
|
- 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.
|
- 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.
|
- Prefer actionable widgets (cards, forms, tabs, inputs, metrics, tables, charts) when they reduce follow-up friction.
|
||||||
- Keep textual guidance and UI semantically consistent.
|
- Keep textual guidance and UI semantically consistent.
|
||||||
- Include only valid, supported action names. Supported actions include: openSettings, openPost, openMedia, openPanel, setActiveView, toggleSidebar, togglePanel, toggleAssistantSidebar.
|
- Include only valid, supported action names. Supported actions include: openSettings, openPost, openMedia, openPanel, setActiveView, toggleSidebar, togglePanel, toggleAssistantSidebar.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { MediaEngine } from './MediaEngine';
|
|||||||
import { getPostMediaEngine } from './PostMediaEngine';
|
import { getPostMediaEngine } from './PostMediaEngine';
|
||||||
import { ProtocolResponseBuilder } from '../agentic/protocol/responseBuilder';
|
import { ProtocolResponseBuilder } from '../agentic/protocol/responseBuilder';
|
||||||
import { CapabilityRegistryService } from '../agentic/capabilities/registry';
|
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 type { ProtocolResponseEnvelope } from '../agentic/protocol/types';
|
||||||
import { AgentTurnStateMachine, type AgentTurnState } from '../agentic/workflow/turnStateMachine';
|
import { AgentTurnStateMachine, type AgentTurnState } from '../agentic/workflow/turnStateMachine';
|
||||||
import { WorkflowCheckpointStore } from '../agentic/workflow/checkpointStore';
|
import { WorkflowCheckpointStore } from '../agentic/workflow/checkpointStore';
|
||||||
@@ -149,6 +149,15 @@ export class OpenCodeManager {
|
|||||||
private apiKey: string = '';
|
private apiKey: string = '';
|
||||||
private abortControllers: Map<string, AbortController> = new Map();
|
private abortControllers: Map<string, AbortController> = 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(
|
constructor(
|
||||||
chatEngine: ChatEngine,
|
chatEngine: ChatEngine,
|
||||||
postEngine: PostEngine,
|
postEngine: PostEngine,
|
||||||
@@ -294,6 +303,7 @@ export class OpenCodeManager {
|
|||||||
// Get system prompt
|
// Get system prompt
|
||||||
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
||||||
const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
|
const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
|
||||||
|
const protocolSystemPrompt = `${systemPrompt}\n\n${this.protocolBoundaryInstructions}`;
|
||||||
|
|
||||||
// Build message history from DB (excluding system messages)
|
// Build message history from DB (excluding system messages)
|
||||||
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
|
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
|
||||||
@@ -339,29 +349,34 @@ export class OpenCodeManager {
|
|||||||
let fullResponse = '';
|
let fullResponse = '';
|
||||||
const toolCallsCollected: Array<{ name: string; args: unknown }> = [];
|
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 {
|
try {
|
||||||
console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId);
|
console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId);
|
||||||
if (provider === 'anthropic') {
|
const firstResult = await requestProvider(protocolSystemPrompt, dbMessages);
|
||||||
const result = await this.sendAnthropicMessage(
|
fullResponse = firstResult.content;
|
||||||
modelId,
|
toolCallsCollected.push(...firstResult.toolCalls);
|
||||||
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);
|
|
||||||
}
|
|
||||||
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[OpenCodeManager] Request error:', (error as Error).message);
|
console.error('[OpenCodeManager] Request error:', (error as Error).message);
|
||||||
@@ -374,23 +389,55 @@ export class OpenCodeManager {
|
|||||||
this.abortControllers.delete(conversationId);
|
this.abortControllers.delete(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save assistant response (including partial content from aborted requests)
|
const isCanonicalProtocolEnvelope = (() => {
|
||||||
if (fullResponse) {
|
try {
|
||||||
await this.chatEngine.addMessage({
|
const parsed = JSON.parse(fullResponse);
|
||||||
conversationId,
|
const validated = validateProtocolResponseEnvelope(parsed);
|
||||||
role: 'assistant',
|
return validated.ok;
|
||||||
content: fullResponse,
|
} catch {
|
||||||
toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined,
|
return false;
|
||||||
createdAt: new Date(),
|
}
|
||||||
});
|
})();
|
||||||
}
|
|
||||||
|
|
||||||
const protocolResult = this.protocolResponseBuilder.build({
|
let protocolResult = this.protocolResponseBuilder.build({
|
||||||
rawAssistantOutput: fullResponse,
|
rawAssistantOutput: fullResponse,
|
||||||
surface,
|
surface,
|
||||||
capabilities,
|
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 previousCheckpoint = await this.workflowCheckpointStore.load(conversationId);
|
||||||
const previousState: AgentTurnState = previousCheckpoint?.state || 'planning';
|
const previousState: AgentTurnState = previousCheckpoint?.state || 'planning';
|
||||||
const nextState = this.turnStateMachine.transition({
|
const nextState = this.turnStateMachine.transition({
|
||||||
@@ -417,6 +464,17 @@ export class OpenCodeManager {
|
|||||||
blockedActions: blockedActionWarnings.length,
|
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
|
// Generate title after first exchange
|
||||||
const userMsgCount = conversation.messages.filter(m => m.role === 'user').length;
|
const userMsgCount = conversation.messages.filter(m => m.role === 'user').length;
|
||||||
if (userMsgCount === 0 && fullResponse) {
|
if (userMsgCount === 0 && fullResponse) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { planAssistantRequest } from '../../navigation/assistantConversation';
|
|||||||
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
||||||
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
|
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
|
||||||
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
|
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
|
||||||
|
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
|
||||||
import { ensureConversationId } from '../../navigation/chatSession';
|
import { ensureConversationId } from '../../navigation/chatSession';
|
||||||
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
||||||
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
||||||
@@ -54,6 +55,7 @@ export const AssistantSidebar: React.FC = () => {
|
|||||||
finalizeAssistantTurn,
|
finalizeAssistantTurn,
|
||||||
appendAssistantMessage,
|
appendAssistantMessage,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
|
getStreamingContent,
|
||||||
} = useChatSurfaceState();
|
} = useChatSurfaceState();
|
||||||
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
|
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[])
|
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
|
||||||
: toClarificationElements(sendResult.envelope.needsInput);
|
: toClarificationElements(sendResult.envelope.needsInput);
|
||||||
setPanelElements(uiElements);
|
setPanelElements(uiElements);
|
||||||
setActionPolicies(
|
setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope));
|
||||||
sendResult.envelope.actions.reduce<Record<string, 'silent' | 'confirm' | 'danger'>>((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({});
|
|
||||||
} else {
|
} else {
|
||||||
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
|
const assistantContent = getStreamingContent() || sendResult.message;
|
||||||
stopStreaming();
|
if (assistantContent) {
|
||||||
|
const parsedResponse = extractAssistantResponseContent(assistantContent);
|
||||||
|
finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText);
|
||||||
|
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
|
||||||
|
setActionPolicies({});
|
||||||
|
} else {
|
||||||
|
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
|
||||||
|
stopStreaming();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
@@ -230,17 +230,13 @@ export const AssistantSidebar: React.FC = () => {
|
|||||||
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
|
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
|
||||||
: toClarificationElements(sendResult.envelope.needsInput);
|
: toClarificationElements(sendResult.envelope.needsInput);
|
||||||
setPanelElements(uiElements);
|
setPanelElements(uiElements);
|
||||||
setActionPolicies(
|
setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope));
|
||||||
sendResult.envelope.actions.reduce<Record<string, 'silent' | 'confirm' | 'danger'>>((accumulator, action) => {
|
|
||||||
accumulator[action.action] = action.policy;
|
|
||||||
return accumulator;
|
|
||||||
}, {}),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendResult.message) {
|
const assistantContent = getStreamingContent() || sendResult.message;
|
||||||
const parsedResponse = extractAssistantResponseContent(sendResult.message);
|
if (assistantContent) {
|
||||||
|
const parsedResponse = extractAssistantResponseContent(assistantContent);
|
||||||
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
|
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
|
||||||
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
|
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
|
||||||
setActionPolicies({});
|
setActionPolicies({});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
|||||||
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
||||||
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
|
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
|
||||||
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
|
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
|
||||||
|
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { ChatTranscript } from '../ChatSurface';
|
import { ChatTranscript } from '../ChatSurface';
|
||||||
import { AssistantPanelControls } from '../AssistantPanelControls';
|
import { AssistantPanelControls } from '../AssistantPanelControls';
|
||||||
@@ -198,12 +199,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
? (result.envelope.ui?.elements as AssistantPanelElement[])
|
? (result.envelope.ui?.elements as AssistantPanelElement[])
|
||||||
: toClarificationElements(result.envelope.needsInput);
|
: toClarificationElements(result.envelope.needsInput);
|
||||||
setPanelElements(uiElements);
|
setPanelElements(uiElements);
|
||||||
setActionPolicies(
|
setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope));
|
||||||
result.envelope.actions.reduce<Record<string, 'silent' | 'confirm' | 'danger'>>((accumulator, action) => {
|
|
||||||
accumulator[action.action] = action.policy;
|
|
||||||
return accumulator;
|
|
||||||
}, {}),
|
|
||||||
);
|
|
||||||
} else if (assistantContent) {
|
} else if (assistantContent) {
|
||||||
const parsedResponse = extractAssistantResponseContent(assistantContent);
|
const parsedResponse = extractAssistantResponseContent(assistantContent);
|
||||||
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
|
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
|
||||||
@@ -272,12 +268,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
? (result.envelope.ui?.elements as AssistantPanelElement[])
|
? (result.envelope.ui?.elements as AssistantPanelElement[])
|
||||||
: toClarificationElements(result.envelope.needsInput);
|
: toClarificationElements(result.envelope.needsInput);
|
||||||
setPanelElements(uiElements);
|
setPanelElements(uiElements);
|
||||||
setActionPolicies(
|
setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope));
|
||||||
result.envelope.actions.reduce<Record<string, 'silent' | 'confirm' | 'danger'>>((accumulator, action) => {
|
|
||||||
accumulator[action.action] = action.policy;
|
|
||||||
return accumulator;
|
|
||||||
}, {}),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,15 +170,35 @@ function toRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
|
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
const chartType = record.chartType;
|
||||||
const normalized: Record<string, unknown> = {
|
const normalized: Record<string, unknown> = {
|
||||||
...record,
|
type: 'chart',
|
||||||
|
chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar',
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataRecord = toRecord(record.data);
|
if (typeof record.title === 'string' && record.title.trim().length > 0) {
|
||||||
if (Array.isArray(record.series)) {
|
normalized.title = record.title;
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!dataRecord) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -204,7 +224,6 @@ function normalizeChartElement(record: Record<string, unknown>): Record<string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalized.series = series;
|
normalized.series = series;
|
||||||
delete normalized.data;
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +288,10 @@ function normalizeElement(value: unknown): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const type = typeof record.type === 'string' ? record.type : '';
|
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') {
|
if (type === 'markdown') {
|
||||||
const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : '';
|
const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : '';
|
||||||
if (!textValue.trim()) {
|
if (!textValue.trim()) {
|
||||||
@@ -302,6 +325,10 @@ function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (record.protocolVersion === '2.0' && record.ui) {
|
||||||
|
return normalizeCandidate(record.ui);
|
||||||
|
}
|
||||||
|
|
||||||
if (record.type === 'tab' && record.content) {
|
if (record.type === 'tab' && record.content) {
|
||||||
return normalizeCandidate(record.content);
|
return normalizeCandidate(record.content);
|
||||||
}
|
}
|
||||||
@@ -366,9 +393,9 @@ export function extractAssistantPanelSpec(message: string): AssistantPanelSpec |
|
|||||||
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
|
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
|
||||||
const trimmed = message.trim();
|
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) {
|
for (const match of fencedMatches) {
|
||||||
const candidate = match[2]?.trim();
|
const candidate = match[1]?.trim();
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -384,8 +411,21 @@ export function extractAssistantResponseContent(message: string): AssistantRespo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsedWholeMessage = parseSpecCandidate(trimmed);
|
const parsedWholeMessage = parseSpecCandidate(trimmed);
|
||||||
|
let displayText = parsedWholeMessage ? '' : trimmed;
|
||||||
|
|
||||||
|
if (parsedWholeMessage) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
if (parsed.protocolVersion === '2.0' && typeof parsed.assistantText === 'string') {
|
||||||
|
displayText = parsed.assistantText;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayText: parsedWholeMessage ? '' : trimmed,
|
displayText,
|
||||||
panelSpec: parsedWholeMessage,
|
panelSpec: parsedWholeMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/renderer/navigation/protocolActionPolicies.ts
Normal file
18
src/renderer/navigation/protocolActionPolicies.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ProtocolResponseEnvelope } from '../types/electron';
|
||||||
|
|
||||||
|
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
|
||||||
|
|
||||||
|
export function buildActionPoliciesFromEnvelope(
|
||||||
|
envelope: Pick<ProtocolResponseEnvelope, 'actions' | 'needsInput'>,
|
||||||
|
): Record<string, ActionPolicyLevel> {
|
||||||
|
const policies = envelope.actions.reduce<Record<string, ActionPolicyLevel>>((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;
|
||||||
|
}
|
||||||
@@ -186,7 +186,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
|||||||
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
|
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.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
|
||||||
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
|
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.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
|
||||||
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
|
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
|
||||||
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
|
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: '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<string, unknown>', 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',
|
name: 'ProtocolTelemetrySnapshot',
|
||||||
description: 'Aggregated protocol telemetry metrics for AGUI response health.',
|
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 = {
|
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
||||||
version: '1.4.0',
|
version: '1.5.0',
|
||||||
generatedAt: '2026-02-25T00:00:00.000Z',
|
generatedAt: '2026-02-25T00:00:00.000Z',
|
||||||
methods: METHODS_V1,
|
methods: METHODS_V1,
|
||||||
dataStructures: DATA_STRUCTURES_V1,
|
dataStructures: DATA_STRUCTURES_V1,
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ describe('OpenCodeManager protocol integration', () => {
|
|||||||
const telemetryAfter = getProtocolTelemetryService().getSnapshot();
|
const telemetryAfter = getProtocolTelemetryService().getSnapshot();
|
||||||
expect(telemetryAfter.totalTurns).toBe(telemetryBefore.totalTurns + 1);
|
expect(telemetryAfter.totalTurns).toBe(telemetryBefore.totalTurns + 1);
|
||||||
expect(telemetryAfter.validEnvelopeTurns).toBe(telemetryBefore.validEnvelopeTurns + 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 () => {
|
it('blocks unsupported actions and records blocked-action telemetry', async () => {
|
||||||
@@ -149,4 +155,71 @@ describe('OpenCodeManager protocol integration', () => {
|
|||||||
const telemetryAfter = getProtocolTelemetryService().getSnapshot();
|
const telemetryAfter = getProtocolTelemetryService().getSnapshot();
|
||||||
expect(telemetryAfter.blockedActionCount).toBe(telemetryBefore.blockedActionCount + 1);
|
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.',
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -171,4 +171,92 @@ describe('ProtocolResponseBuilder', () => {
|
|||||||
requiresConfirmation: false,
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -192,4 +192,49 @@ describe('assistantPanelSpec', () => {
|
|||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.elements).toHaveLength(7);
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
56
tests/renderer/navigation/protocolActionPolicies.test.ts
Normal file
56
tests/renderer/navigation/protocolActionPolicies.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
it('contains semantic version metadata for compatibility checks', () => {
|
||||||
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||||
version: '1.4.0',
|
version: '1.5.0',
|
||||||
generatedAt: expect.any(String),
|
generatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -56,6 +81,7 @@ describe('pythonApiContractV1', () => {
|
|||||||
expect.objectContaining({ name: 'PostData' }),
|
expect.objectContaining({ name: 'PostData' }),
|
||||||
expect.objectContaining({ name: 'MediaData' }),
|
expect.objectContaining({ name: 'MediaData' }),
|
||||||
expect.objectContaining({ name: 'ProjectData' }),
|
expect.objectContaining({ name: 'ProjectData' }),
|
||||||
|
expect.objectContaining({ name: 'ProtocolResponseEnvelope' }),
|
||||||
expect.objectContaining({ name: 'ProtocolTelemetrySnapshot' }),
|
expect.objectContaining({ name: 'ProtocolTelemetrySnapshot' }),
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
@@ -76,7 +102,7 @@ describe('generatePythonApiModuleV1', () => {
|
|||||||
expect(moduleCode).toContain('async def search(self, query):');
|
expect(moduleCode).toContain('async def search(self, query):');
|
||||||
expect(moduleCode).toContain('async def get_project_metadata(self):');
|
expect(moduleCode).toContain('async def get_project_metadata(self):');
|
||||||
expect(moduleCode).toContain('async def get_conversations(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('class BdsApi:');
|
||||||
expect(moduleCode).toContain('bds = BdsApi(_transport)');
|
expect(moduleCode).toContain('bds = BdsApi(_transport)');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user