Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 93 additions & 27 deletions .github/prompts/conversational-poc-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,77 @@ Completed checklist for 1.5:

Carry-forward checklist (to avoid losing architectural intent in 1.6/1.7/1.8):

- [ ] **1.6** Move conversation endpoint terminal lifecycle transitions fully into event dispatcher flow (`pending -> streaming -> completed/failed/aborted`) so endpoint status represents full turn+stream lifecycle
- [ ] **1.6** Ensure stream connectivity (`streaming.isConnected`) is driven by stream open/close events rather than request completion timing
- [ ] **1.7** Keep conversation runtime as owner of stream consumption and terminal status/error mutations; facade should remain transport/request orchestration only
- [ ] **1.7** Add runtime-level safeguards for overlapping submissions/aborts so concurrent turns cannot leave endpoint status in inconsistent state
- [ ] **1.8** Compose controller state from conversation domain + conversation endpoint state and verify subscribe semantics remain stable across both slices
- [ ] **1.8** Add controller-focused tests asserting lifecycle visibility (loading/streaming/error) against runtime-driven transitions
- [x] **1.6** Move conversation endpoint terminal lifecycle transitions fully into event dispatcher flow (`pending -> streaming -> completed/failed/aborted`) so endpoint status represents full turn+stream lifecycle
- [x] **1.6** Ensure stream connectivity (`streaming.isConnected`) is driven by stream open/close events rather than request completion timing
- [x] **1.7** Keep conversation runtime as owner of stream consumption and terminal status/error mutations; facade should remain transport/request orchestration only
- [x] **1.7** Add runtime-level safeguards for overlapping submissions/aborts so concurrent turns cannot leave endpoint status in inconsistent state
- [x] **1.8** Compose controller state from conversation domain + conversation endpoint state and verify subscribe semantics remain stable across both slices
- [x] **1.8** Add controller-focused tests asserting lifecycle visibility (loading/streaming/error) against runtime-driven transitions

Completed checklist for 1.6:

- [x] Added turn lifecycle helper module for stream-open promotion, terminal success/failure transitions, and missing-terminal interruption handling under `src/core/interface/api/conversation-endpoint/`
- [x] Added pure normalized-event dispatcher under `src/core/interface/api/conversation-endpoint/` to map protocol events to state mutations
- [x] Implemented session continuity updates on `turn_started` events in dispatcher output
- [x] Implemented protocol failure mapping (`RUN_ERROR` -> `protocol_error`) and warning semantics for `UNKNOWN`/`CUSTOM` events without interrupting the stream
- [x] Updated conversation endpoint facade to keep lifecycle in `pending` after successful call and defer `streaming` promotion to stream lifecycle/dispatcher flow
- [x] Added unit coverage for lifecycle helpers, event dispatcher, and updated facade lifecycle behavior
- [x] Hardened conversation endpoint facade error handling to normalize unexpected thrown errors into failure results and reset endpoint lifecycle (`status: idle`, `streaming.isConnected: false`) to avoid pending-state leaks
- [x] Verified package health with `pnpm --filter @coveo/headless-future test && pnpm --filter @coveo/headless-future build`

Completed checklist for 1.7:

- [x] Added Layer 1 conversation runtime singleton under `src/core/interface/api/conversation-endpoint/conversation-runtime.ts` using engine-scoped `WeakMap` instance management
- [x] Made runtime the owner of turn orchestration: start turn, endpoint call, stream consumption, event dispatch, and terminal lifecycle mutations
- [x] Kept `conversation-endpoint-facade` focused on transport/request orchestration and added optional call options passthrough for abort signal wiring
- [x] Implemented abort behavior for active turns as immediate local terminal mutation (`aborted`) followed by request/stream cancellation
- [x] Implemented overlapping submit safeguard with a simple initial policy: reject while a turn is active and set a user-facing conversation error
- [x] Preserved warning semantics for unknown/custom stream events without interrupting successful terminal completion
- [x] Enforced missing-terminal handling when streams close without terminal events (`stream_interrupted`)
- [x] Added runtime-level guards so late async settlements (post-abort/post-replacement) cannot overwrite active lifecycle state
- [x] Added focused runtime unit coverage in `src/core/interface/api/conversation-endpoint/conversation-runtime.test.ts` for singleton behavior, submit flow, overlap rejection, abort flow, stream lifecycle, warning preservation, and late-settlement race protection
- [x] Exported `ConversationRuntime` through `src/core/index.ts` for controller wiring in Phase 1.8
- [x] Verified package health with `pnpm --filter @coveo/headless-future test && pnpm --filter @coveo/headless-future build`

Completed checklist for 1.8:

- [x] Implemented Layer 2 conversation controller builder in `src/public/controllers/conversation/conversation-controller.ts`
- [x] Wired eager domain loading for conversation and conversation-endpoint slices during controller construction
- [x] Wired runtime delegation for `submitTurn(input)` and `abortTurn()` through `ConversationRuntime`
- [x] Composed controller `state` from conversation selectors (`messages`, `turns`, `activeTurnId`, `session`) and conversation-endpoint selectors (`isLoading`, `error`, `streaming`)
- [x] Stabilized controller subscribe semantics across both slices with a memoized composed selector
- [x] Added a reusable framework-agnostic selector helper (`createMemoizedStateSelector`) under `src/core/interface/utils/` and reused it in the conversation controller state composition
- [x] Updated conversation controller core imports to come through the `src/core/index.ts` barrel and expanded barrel exports for conversation feature selectors/loaders
- [x] Added public-behavior controller tests in `src/public/controllers/conversation/conversation-controller.test.ts` covering state composition, delegation, lifecycle visibility, and cross-slice subscription triggers
- [x] Verified package health with `pnpm --filter @coveo/headless-future test && pnpm --filter @coveo/headless-future build`

Completed checklist for 1.9:

- [x] Wired `samples/headless-future/conversation-react` to `buildConversationController` for real converse endpoint usage
- [x] Updated sample engine initialization to `EngineOptions` style with `navigatorContextProvider` built from browser context
- [x] Initialized cart contribution with an explicit empty cart payload for request composition continuity
- [x] Implemented sample submit UX as single-line Enter-to-submit input
- [x] Implemented conditional abort UX with a Stop button visible only while a turn is active
- [x] Rendered user and agent messages with live streaming updates
- [x] Rendered turn lifecycle status and an in-app raw debug event log panel
- [x] Added inline error banner rendering from conversation controller error state
- [x] Added unit coverage for submit/abort UI behavior in `src/App.test.tsx`
- [x] Added deterministic mocked-stream e2e coverage in `tests/smoke.spec.ts` to validate end-to-end submit/stream rendering
- [x] Verified package health with `pnpm --filter @coveo/headless-future test && pnpm --filter @coveo/headless-future build`

Completed hardening pass after 1.9 (same branch scope, before Phase 2):

- [x] Added a dedicated stream adapter at `src/api/interface/conversation-endpoint/conversation-event-stream.ts` and covered it with focused tests
- [x] Tightened API boundaries so production imports consume `@/src/api/index.js` while tests mock concrete client modules (not the API barrel)
- [x] Added `activeTurnUserMessage` selector and updated `conversation-loader` to consume selector output directly for conversation request contribution
- [x] Decoupled `ConversationRuntime` from direct conversation mutator/selector usage through injected `ConversationRuntimeStatePort`
- [x] Refactored `conversation-event-dispatcher` to pure event -> effects mapping, with runtime applying effects to state
- [x] Removed the thin `conversation-turn-lifecycle` indirection module and inlined endpoint stop/failure transitions in runtime
- [x] Removed conversation slice loading from `conversation-endpoint-facade`; conversation loading is now explicit at controller/test integration points
- [x] Refactored sample app composition into focused components/hooks (`src/components/*`, `src/hooks/use-conversation-event-log.ts`, `src/hooks/use-conversation-composer.ts`) while preserving submit/abort behavior
- [x] Added sample-local controller bootstrap helper at `samples/headless-future/conversation-react/src/conversation-controller.ts` to isolate singleton wiring from UI components
- [x] Stabilized sample resolution by aliasing `@coveo/headless-future` to source entry in sample Vite config to avoid stale `dist/` behavior during local dev/e2e
- [x] Hardened smoke e2e assertions with scoped locators for streamed text and raw event log checks

### Phase 2 — A2UI Surface Parsing

Expand All @@ -222,24 +287,25 @@ Carry-forward checklist (to avoid losing architectural intent in 1.6/1.7/1.8):

## Status Tracker

| Sub-phase | Branch | Status |
| --------- | -------------------------------- | -------------- |
| Phase 0 | add-conversational-support | ✅ completed |
| Phase 0.5 | add-conversational-support | ✅ completed |
| Phase 1.0 | add-conversation-types | ✅ completed |
| Phase 1.1 | add-slice-mutators-and-selectors | ✅ completed |
| Phase 1.2 | add-navigator-context | ✅ completed |
| Phase 1.3 | adjust-cart | ✅ completed |
| Phase 1.4 | add-stream-utils | ✅ completed |
| Phase 1.5 | add-conversation-endpoint | ✅ completed |
| Phase 1.6 | — | ⬜ not started |
| Phase 1.7 | — | ⬜ not started |
| Phase 1.8 | — | ⬜ not started |
| Phase 1.9 | — | ⬜ not started |
| Phase 2.0 | — | ⬜ not started |
| Phase 2.1 | — | ⬜ not started |
| Phase 2.2 | — | ⬜ not started |
| Phase 2.3 | — | ⬜ not started |
| Phase 3.0 | — | ⬜ not started |
| Phase 3.1 | — | ⬜ not started |
| Phase 3.2 | — | ⬜ not started |
| Sub-phase | Branch | Status |
| ------------------- | ---------------------------------------- | -------------- |
| Phase 0 | add-conversational-support | ✅ completed |
| Phase 0.5 | add-conversational-support | ✅ completed |
| Phase 1.0 | add-conversation-types | ✅ completed |
| Phase 1.1 | add-slice-mutators-and-selectors | ✅ completed |
| Phase 1.2 | add-navigator-context | ✅ completed |
| Phase 1.3 | adjust-cart | ✅ completed |
| Phase 1.4 | add-stream-utils | ✅ completed |
| Phase 1.5 | add-conversation-endpoint | ✅ completed |
| Phase 1.6 | add-conversation-runtime-building-blocks | ✅ completed |
| Phase 1.7 | add-conversation-runtime-building-blocks | ✅ completed |
| Phase 1.8 | implement-conversation-controller | ✅ completed |
| Phase 1.9 | implement-conversation-controller | ✅ completed |
| Phase 1.9 hardening | implement-conversation-controller | ✅ completed |
| Phase 2.0 | — | ⬜ not started |
| Phase 2.1 | — | ⬜ not started |
| Phase 2.2 | — | ⬜ not started |
| Phase 2.3 | — | ⬜ not started |
| Phase 3.0 | — | ⬜ not started |
| Phase 3.1 | — | ⬜ not started |
| Phase 3.2 | — | ⬜ not started |
6 changes: 6 additions & 0 deletions packages/headless-future/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,11 @@ export type {
ConversationEndpointClientResult,
} from '@/src/api/interface/conversation-endpoint/conversation-endpoint-client.js';

export type {
ConversationStreamEvent,
ReadConversationEventStreamOptions,
} from '@/src/api/interface/conversation-endpoint/conversation-event-stream.js';

export {createSearchEndpointClient} from '@/src/api/interface/search-endpoint/search-endpoint-client.js';
export {createConversationEndpointClient} from '@/src/api/interface/conversation-endpoint/conversation-endpoint-client.js';
export {readConversationEventStream} from '@/src/api/interface/conversation-endpoint/conversation-event-stream.js';
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ describe('ConversationEndpointClient', () => {
url: 'https://example.com/products',
referrer: 'https://example.com',
},
cart: {
items: [],
},
cart: [],
},
targetEngine: 'AGENT_CORE',
};
Expand Down Expand Up @@ -88,7 +86,7 @@ describe('ConversationEndpointClient', () => {

expect(response.data.stream).toBe(stream);
expect(mockedFetch).toHaveBeenCalledWith(
'https://test-org-id.org.coveo.com/rest/organizations/test-org-id/commerce/unstable/agentic/converse',
'https://test-org-id.admin.org.coveo.com/rest/organizations/test-org-id/commerce/unstable/agentic/converse',
{
method: 'POST',
signal: undefined,
Expand All @@ -97,7 +95,8 @@ describe('ConversationEndpointClient', () => {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: 'Bearer test-token',
'Coveo-Organization-Id': 'test-org-id',
'X-Coveo-Feature-Flags-Overrides':
'{"use-demo-agent-core-runtime":false}',
},
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
} from '@/src/api/internal/protocol/error-handling.js';
import {getOrganizationEndpoint} from '@/src/api/internal/utils/organization-endpoint.js';

const featureFlagOverridesHeaderValue = JSON.stringify({
'use-demo-agent-core-runtime': false,
});

const createCallConversationEndpoint =
(): ConversationEndpointClient['call'] => {
return async (
Expand Down Expand Up @@ -36,6 +40,7 @@ const createCallConversationEndpoint =

const organizationEndpoint = getOrganizationEndpoint(organizationId, {
endpoint,
endpointType: 'admin',
});
const url =
`${organizationEndpoint}` +
Expand All @@ -51,7 +56,7 @@ const createCallConversationEndpoint =
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${accessToken}`,
'Coveo-Organization-Id': organizationId,
'X-Coveo-Feature-Flags-Overrides': featureFlagOverridesHeaderValue,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ export interface CoveoConversationEndpointRequest {
url?: string | null;
referrer?: string | null;
};
cart?: {
items: CoveoConversationCartItem[];
};
cart?: CoveoConversationCartItem[];
};
conversationSessionId?: string;
conversationToken?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {readConversationEventStream} from './conversation-event-stream.js';

const {mockReadEventStream, mockParseSSEEvent} = vi.hoisted(() => {
return {
mockReadEventStream: vi.fn(),
mockParseSSEEvent: vi.fn(),
};
});

vi.mock('@/src/api/internal/protocol/stream.js', () => {
return {
readEventStream: mockReadEventStream,
};
});

vi.mock('@/src/api/internal/protocol/sse-parser.js', () => {
return {
parseSSEEvent: mockParseSSEEvent,
};
});

describe('readConversationEventStream', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('normalizes raw stream events before invoking onEvent', async () => {
const stream = {} as ReadableStream<Uint8Array>;
const onEvent = vi.fn();
const normalizedEvent = {type: 'turn_complete'};

mockParseSSEEvent.mockReturnValue(normalizedEvent);
mockReadEventStream.mockImplementation(
async ({onEvent: onRawEvent, onDone}) => {
onRawEvent({event: 'turn_complete', data: '{}'});
onDone?.();
}
);

await readConversationEventStream({stream, onEvent});

expect(mockParseSSEEvent).toHaveBeenCalledWith({
event: 'turn_complete',
data: '{}',
});
expect(onEvent).toHaveBeenCalledWith(normalizedEvent);
});

it('forwards signal and terminal callbacks to the underlying reader', async () => {
const stream = {} as ReadableStream<Uint8Array>;
const signal = new AbortController().signal;
const onEvent = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();

mockReadEventStream.mockResolvedValue(undefined);

await readConversationEventStream({
stream,
signal,
onEvent,
onDone,
onError,
});

expect(mockReadEventStream).toHaveBeenCalledWith(
expect.objectContaining({
stream,
signal,
onDone,
onError,
onEvent: expect.any(Function),
})
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {parseSSEEvent} from '@/src/api/internal/protocol/sse-parser.js';
import {readEventStream} from '@/src/api/internal/protocol/stream.js';
import type {NormalizedStreamEvent} from '@/src/api/internal/protocol/stream-types.js';

export type ConversationStreamEvent = NormalizedStreamEvent;

export interface ReadConversationEventStreamOptions {
stream: ReadableStream<Uint8Array>;
onEvent: (event: ConversationStreamEvent) => void;
signal?: AbortSignal;
onDone?: () => void;
onError?: (error: unknown) => void;
}

export async function readConversationEventStream({
stream,
onEvent,
signal,
onDone,
onError,
}: ReadConversationEventStreamOptions): Promise<void> {
await readEventStream({
stream,
signal,
onDone,
onError,
onEvent: (rawEvent) => {
onEvent(parseSSEEvent(rawEvent));
},
});
}
Loading
Loading