Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb9f9cda5a | ||
|
|
400a21ca18 | ||
|
|
4cefa5cd88 | ||
|
|
ddf8616716 | ||
|
|
9185b0cce4 | ||
|
|
8925a502ae | ||
|
|
a45f53071a |
@@ -0,0 +1,19 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CoordModule } from './coord.module.js';
|
||||||
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
|
||||||
|
describe('CoordModule DI (compiled-metadata boot)', () => {
|
||||||
|
it('resolves InteractionCoordinationService through Nest DI', async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: (): boolean => true })
|
||||||
|
.compile();
|
||||||
|
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
|
||||||
|
InteractionCoordinationService,
|
||||||
|
);
|
||||||
|
await moduleRef.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
InteractionCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
@@ -13,6 +13,7 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
|||||||
|
|
||||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
|
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
|
||||||
|
|
||||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
@@ -60,6 +61,8 @@ export class InteractionCoordinationService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
|
@Optional()
|
||||||
|
@Inject(HANDOFF_ID_FACTORY)
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# WebUI Phase P — File / Folder Structure & Migration Map
|
||||||
|
|
||||||
|
> **Status:** living document — first pass. Structure and increment status are verified against
|
||||||
|
> `next` as of merge `8c27024d`. Details (per-surface component inventories, exact route tables,
|
||||||
|
> test matrices) are still being fleshed out; extend the stub sections below rather than rewriting
|
||||||
|
> the verified structure.
|
||||||
|
|
||||||
|
## 1. What Phase P is
|
||||||
|
|
||||||
|
Phase P migrates the Mosaic **web UI** (`apps/web`) from the legacy **Next.js App Router** app to a
|
||||||
|
**Vite + React Router single-page app (SPA)** that the **Gateway serves same-origin** on
|
||||||
|
`:14242`. The RFC splits the work into **six increments (P1–P6)**; the P1 PR title records this as
|
||||||
|
"increment 1/6".
|
||||||
|
|
||||||
|
The migration is deliberately **incremental and non-destructive**: the new SPA is built up
|
||||||
|
_beside_ the existing Next app, sharing one `apps/web/src/lib` networking/auth layer, until the
|
||||||
|
final cutover (P5) removes the Next tree. At every point in between, **both app trees exist in the
|
||||||
|
same package** — this is intentional, not drift.
|
||||||
|
|
||||||
|
## 2. Current tree on `next` (dual-app, transitional)
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/
|
||||||
|
├── next.config.ts # legacy Next.js config (removed at P5)
|
||||||
|
├── vite.config.ts # SPA build + DEV proxy config (canonical from P5)
|
||||||
|
├── package.json # dev/build default to NEXT today; :vite variants opt in
|
||||||
|
└── src/
|
||||||
|
├── main.tsx # ── SPA entry (Vite)
|
||||||
|
├── routes.tsx # ── SPA React Router route table
|
||||||
|
├── spa/ # ── NEW SPA surfaces
|
||||||
|
│ ├── guards.tsx # guest / authenticated route guards
|
||||||
|
│ ├── pages/ # login, register, sso-callback (P2); chat + error boundary (P3)
|
||||||
|
│ └── chat/ # P3 typed chat: use-chat-connection, commands-panel,
|
||||||
|
│ # session-panel, message-transcript, tool-call-list, composer
|
||||||
|
│
|
||||||
|
├── lib/ # ── SHARED by BOTH trees (origin-relative networking + auth)
|
||||||
|
│ ├── api.ts # fetch wrapper — relative /api/...
|
||||||
|
│ ├── socket.ts # Socket.IO singleton — relative /chat
|
||||||
|
│ ├── auth-client.ts # BetterAuth client — relative /api/auth/...
|
||||||
|
│ ├── auth-redirect.ts # post-auth redirect resolution (protocol-relative rejected)
|
||||||
|
│ ├── chat-contract.ts # P3 typed chat wire contract (runtime-guarded)
|
||||||
|
│ ├── sso.ts · types.ts · cn.ts
|
||||||
|
│
|
||||||
|
├── app/ # ══ LEGACY Next.js App Router (removed at P5)
|
||||||
|
│ ├── (auth)/{login,register}/
|
||||||
|
│ ├── (dashboard)/{admin,chat,projects,projects/[id],settings,tasks}/
|
||||||
|
│ ├── auth/provider/[provider]/
|
||||||
|
│ └── layout.tsx · page.tsx · globals.css
|
||||||
|
│
|
||||||
|
├── components/ # ══ LEGACY Next component library (auth, chat, layout,
|
||||||
|
│ # projects, settings, tasks, ui) — ported into spa/ across P3/P4
|
||||||
|
└── providers/ # ══ theme-provider (legacy; SPA equivalent under providers)
|
||||||
|
```
|
||||||
|
|
||||||
|
Legend: `──` new SPA (keep), `══` legacy Next (removed at P5), shared `lib/` in the middle.
|
||||||
|
|
||||||
|
## 3. Networking / serving model (why it's same-origin)
|
||||||
|
|
||||||
|
- The SPA speaks **origin-relative paths only**: `/api/...`, `/api/auth/...`, `/chat`. No
|
||||||
|
`NEXT_PUBLIC_*` / `VITE_*` origin var, no hard-coded `http://localhost:14242` under
|
||||||
|
`apps/web/src`.
|
||||||
|
- **Dev:** `vite.config.ts` runs a dev-only proxy that forwards those paths to the Gateway (so the
|
||||||
|
SPA on its dev port and the Gateway on `:14242` behave as one origin).
|
||||||
|
- **Prod (target):** the SPA is **same-origin with the Gateway** — the Gateway serves the built
|
||||||
|
static bundle and the API/WS on `:14242`, so no proxy and no CORS. _(The Gateway does not serve
|
||||||
|
the web `dist` yet — adding that is the core of P5; see §5.)_
|
||||||
|
|
||||||
|
## 4. Build scripts (`apps/web/package.json`)
|
||||||
|
|
||||||
|
| Script | Today | Notes |
|
||||||
|
| ----------------------------- | -------------------------------------------- | ------------------------------ |
|
||||||
|
| `dev` | `next dev` | legacy dev server |
|
||||||
|
| `dev:vite` | `vite` | SPA dev server (+ dev proxy) |
|
||||||
|
| `build` | `node ../../scripts/build-web.mjs` | currently a **Next** build |
|
||||||
|
| `build:vite` | `vite build` | SPA production build → `dist/` |
|
||||||
|
| `lint` / `typecheck` / `test` | `eslint src` / `tsc --noEmit` / `vitest run` | tree-agnostic |
|
||||||
|
|
||||||
|
At **P5** the `:vite` variants become the defaults (`dev`→vite, `build`→vite build) and the Next
|
||||||
|
build path is retired.
|
||||||
|
|
||||||
|
## 5. Increment map (P1–P6)
|
||||||
|
|
||||||
|
| # | Increment | Branch | Status |
|
||||||
|
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------- |
|
||||||
|
| **P1** | Vite + React Router skeleton beside Next (entry, router, guards, vitest) | `feat/webui-p1-vite-skeleton` | ✅ merged — PR **#1143** |
|
||||||
|
| **P2** | SPA data layer + same-origin auth (login/register/SSO pages, guards, relative api/socket/auth-client) | `feat/webui-p2-data-auth` | ✅ merged — PR **#1144** |
|
||||||
|
| **P3** | Typed SPA **chat** (`spa/chat/*`, `chat-contract.ts`, chat page + error boundary) | `feat/webui-p3-chat` | 🚧 in progress (unmerged) |
|
||||||
|
| **P4** | Port **projects / tasks / settings / admin** dashboard surfaces into the SPA | _tbd_ | ⏳ not started |
|
||||||
|
| **P5** | **Cutover**: Gateway serves the Vite `dist` on `:14242`; flip `dev`/`build` to vite; **remove** the legacy Next `app/` tree + `next.config.ts` | _tbd_ | ⏳ not started |
|
||||||
|
| **P6** | CI / images (trails): build the SPA in CI, ship images | _tbd_ | ⏳ trails |
|
||||||
|
|
||||||
|
Each increment follows the same delivery pipeline: brief traceable to the RFC → author →
|
||||||
|
**independent** integrator verification (build+test+typecheck+lint) → **independent** code + security
|
||||||
|
review (author ≠ reviewer) → author remediates → branch + PR to `next` → **independent** merge-gate
|
||||||
|
merges. Author self-reports are not trusted; every gate is re-derived independently.
|
||||||
|
|
||||||
|
## 6. Known dependency / blocker
|
||||||
|
|
||||||
|
- **Issue #1145 — Gateway `dist` boot is broken** (DI failure on a defaulted constructor param);
|
||||||
|
the Gateway currently runs **dev-mode only**. This is a **hard precondition for P5**: the Gateway
|
||||||
|
cannot serve the SPA `dist` on `:14242` until `dist` boot works. P3/P4 remain on the dev-proxy
|
||||||
|
topology meanwhile.
|
||||||
|
|
||||||
|
## 7. Not part of Phase P (disambiguation)
|
||||||
|
|
||||||
|
`docs/plans/2026-08-09-webui-fleet-claude-bridge.md` and
|
||||||
|
`docs/scratchpads/webui-fleet-bridge-plan.md` describe a **separate** WebUI ↔ fleet/Claude bridge
|
||||||
|
effort. They are **not** the Phase P SPA migration and should not be conflated with the increments
|
||||||
|
above.
|
||||||
|
|
||||||
|
## 8. Where the detail lives (extend these)
|
||||||
|
|
||||||
|
- Per-increment working notes: `docs/scratchpads/webui-p*-*.md` (e.g. `webui-p2-data-auth.md`).
|
||||||
|
- _Stub — to flesh out:_ per-surface component inventory (which `components/*` port to which
|
||||||
|
`spa/*`), the full SPA route table, the P5 cutover checklist, and the P6 CI/image plan.
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { describe, expect, expectTypeOf, it } from 'vitest';
|
||||||
|
import { HARNESS_CAPABILITIES, HARNESS_ERROR_CODES } from './index.js';
|
||||||
|
import type {
|
||||||
|
AttachConversation,
|
||||||
|
ConversationSnapshot,
|
||||||
|
CreateHarnessSession,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessCapability,
|
||||||
|
HarnessConversationService,
|
||||||
|
HarnessDescriptor,
|
||||||
|
HarnessError,
|
||||||
|
HarnessErrorCode,
|
||||||
|
HarnessEvent,
|
||||||
|
HarnessEventEnvelope,
|
||||||
|
HarnessInteractionState,
|
||||||
|
HarnessPrompt,
|
||||||
|
HarnessPromptReceipt,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSessionHandle,
|
||||||
|
HarnessSessionSnapshot,
|
||||||
|
ResumeHarnessSession,
|
||||||
|
SendHarnessTurn,
|
||||||
|
TurnReceipt,
|
||||||
|
} from '../index.js';
|
||||||
|
|
||||||
|
const EXPECTED_CAPABILITIES = [
|
||||||
|
'modelSelection',
|
||||||
|
'thinkingLevels',
|
||||||
|
'images',
|
||||||
|
'toolEvents',
|
||||||
|
'extensionUi',
|
||||||
|
'steering',
|
||||||
|
'followUp',
|
||||||
|
'compaction',
|
||||||
|
'persistentResume',
|
||||||
|
] as const satisfies readonly HarnessCapability[];
|
||||||
|
|
||||||
|
const EXPECTED_ERROR_CODES = [
|
||||||
|
'auth_required',
|
||||||
|
'selection_invalid',
|
||||||
|
'catalog_unavailable',
|
||||||
|
'catalog_stale',
|
||||||
|
'model_unavailable',
|
||||||
|
'no_viable_provider',
|
||||||
|
'session_create_failed',
|
||||||
|
'session_not_found',
|
||||||
|
'resume_conflict',
|
||||||
|
'session_busy',
|
||||||
|
'auth_bundle_concurrency_unverified',
|
||||||
|
'adapter_unavailable',
|
||||||
|
'sandbox_unavailable',
|
||||||
|
'rpc_version_unsupported',
|
||||||
|
'rpc_protocol_error',
|
||||||
|
'process_exited',
|
||||||
|
'outcome_unknown',
|
||||||
|
'interaction_unsupported',
|
||||||
|
'aborted',
|
||||||
|
] as const satisfies readonly HarnessErrorCode[];
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unexpected contract variant: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeEvent(event: HarnessEvent): string {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'session.started':
|
||||||
|
case 'session.state':
|
||||||
|
case 'session.identity_changed':
|
||||||
|
case 'turn.started':
|
||||||
|
case 'text.delta':
|
||||||
|
case 'thinking.delta':
|
||||||
|
case 'tool.started':
|
||||||
|
case 'tool.updated':
|
||||||
|
case 'tool.finished':
|
||||||
|
case 'interaction.required':
|
||||||
|
case 'usage.updated':
|
||||||
|
case 'turn.completed':
|
||||||
|
case 'error':
|
||||||
|
return event.type;
|
||||||
|
default:
|
||||||
|
return assertNever(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(error: HarnessError): HarnessErrorCode {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'auth_required':
|
||||||
|
case 'selection_invalid':
|
||||||
|
case 'catalog_unavailable':
|
||||||
|
case 'catalog_stale':
|
||||||
|
case 'model_unavailable':
|
||||||
|
case 'no_viable_provider':
|
||||||
|
case 'session_create_failed':
|
||||||
|
case 'session_not_found':
|
||||||
|
case 'resume_conflict':
|
||||||
|
case 'session_busy':
|
||||||
|
case 'auth_bundle_concurrency_unverified':
|
||||||
|
case 'adapter_unavailable':
|
||||||
|
case 'sandbox_unavailable':
|
||||||
|
case 'rpc_version_unsupported':
|
||||||
|
case 'rpc_protocol_error':
|
||||||
|
case 'process_exited':
|
||||||
|
case 'outcome_unknown':
|
||||||
|
case 'interaction_unsupported':
|
||||||
|
case 'aborted':
|
||||||
|
return error.code;
|
||||||
|
default:
|
||||||
|
return assertNever(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generic harness contracts', (): void => {
|
||||||
|
it('keeps harness, provider, model, conversation, native session, process, and seat separate', (): void => {
|
||||||
|
const selection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai-codex',
|
||||||
|
modelId: 'gpt-5-codex',
|
||||||
|
} satisfies HarnessSelection;
|
||||||
|
const snapshot = {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
nativeSessionId: 'native-session-1',
|
||||||
|
processId: 'process-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
selection,
|
||||||
|
state: 'idle',
|
||||||
|
attachedClientIds: ['browser-1'],
|
||||||
|
} satisfies HarnessSessionSnapshot;
|
||||||
|
|
||||||
|
const identifiers = [
|
||||||
|
snapshot.selection.harnessId,
|
||||||
|
snapshot.selection.providerId,
|
||||||
|
snapshot.selection.modelId,
|
||||||
|
snapshot.conversationId,
|
||||||
|
snapshot.nativeSessionId,
|
||||||
|
snapshot.processId,
|
||||||
|
snapshot.seatId,
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(new Set(identifiers).size).toBe(7);
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
nativeSessionId: 'native-session-1',
|
||||||
|
processId: 'process-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advertises the complete capability set as checked literals', (): void => {
|
||||||
|
const descriptor = {
|
||||||
|
id: 'pi',
|
||||||
|
displayName: 'Pi',
|
||||||
|
capabilities: HARNESS_CAPABILITIES,
|
||||||
|
} satisfies HarnessDescriptor;
|
||||||
|
|
||||||
|
expect(HARNESS_CAPABILITIES).toEqual(EXPECTED_CAPABILITIES);
|
||||||
|
expect(descriptor.capabilities).toEqual(EXPECTED_CAPABILITIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes every stable error code as an exhaustive discriminated union', (): void => {
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai-codex',
|
||||||
|
modelId: 'gpt-5-codex',
|
||||||
|
};
|
||||||
|
const error: HarnessError = {
|
||||||
|
code: 'model_unavailable',
|
||||||
|
message: 'The selected model is unavailable.',
|
||||||
|
retryable: true,
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
selection,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(HARNESS_ERROR_CODES).toEqual(EXPECTED_ERROR_CODES);
|
||||||
|
expect(describeError(error)).toBe('model_unavailable');
|
||||||
|
const receipt = {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
turnId: 'turn-1',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
state: 'accepted',
|
||||||
|
selection,
|
||||||
|
} satisfies HarnessPromptReceipt;
|
||||||
|
|
||||||
|
expect(error.selection).toEqual(selection);
|
||||||
|
expect(receipt.selection).toEqual(selection);
|
||||||
|
expect('effectiveSelection' in error).toBe(false);
|
||||||
|
expect('effectiveSelection' in receipt).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps every normalized event variant in the persisted envelope', (): void => {
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai-codex',
|
||||||
|
modelId: 'gpt-5-codex',
|
||||||
|
};
|
||||||
|
const toolStarted: HarnessEvent = {
|
||||||
|
type: 'tool.started',
|
||||||
|
toolCallId: 'tool-call-1',
|
||||||
|
toolName: 'read',
|
||||||
|
};
|
||||||
|
const events: readonly HarnessEvent[] = [
|
||||||
|
{ type: 'session.started', state: 'idle' },
|
||||||
|
{ type: 'session.state', state: 'busy' },
|
||||||
|
{
|
||||||
|
type: 'session.identity_changed',
|
||||||
|
identityGeneration: 2,
|
||||||
|
label: 'Re-enrolled account',
|
||||||
|
},
|
||||||
|
{ type: 'turn.started' },
|
||||||
|
{ type: 'text.delta', text: 'Hello' },
|
||||||
|
{ type: 'thinking.delta', text: 'Reasoning' },
|
||||||
|
toolStarted,
|
||||||
|
{
|
||||||
|
type: 'tool.updated',
|
||||||
|
toolCallId: 'tool-call-1',
|
||||||
|
toolName: 'read',
|
||||||
|
message: 'Reading',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool.finished',
|
||||||
|
toolCallId: 'tool-call-1',
|
||||||
|
toolName: 'read',
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'interaction.required',
|
||||||
|
requestId: 'interaction-1',
|
||||||
|
interactionType: 'confirm',
|
||||||
|
state: 'pending',
|
||||||
|
prompt: 'Continue?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage.updated',
|
||||||
|
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||||
|
},
|
||||||
|
{ type: 'turn.completed', outcome: 'settled' },
|
||||||
|
{
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
code: 'rpc_protocol_error',
|
||||||
|
message: 'The harness protocol failed.',
|
||||||
|
retryable: false,
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
selection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const envelope: HarnessEventEnvelope = {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
nativeSessionId: 'native-session-1',
|
||||||
|
turnId: 'turn-1',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
sequence: 42,
|
||||||
|
nativeEntryCursor: 'native-entry-7',
|
||||||
|
occurredAt: '2026-08-11T12:00:00.000Z',
|
||||||
|
harnessId: 'pi',
|
||||||
|
selection,
|
||||||
|
event: toolStarted,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(events.map(describeEvent)).toEqual([
|
||||||
|
'session.started',
|
||||||
|
'session.state',
|
||||||
|
'session.identity_changed',
|
||||||
|
'turn.started',
|
||||||
|
'text.delta',
|
||||||
|
'thinking.delta',
|
||||||
|
'tool.started',
|
||||||
|
'tool.updated',
|
||||||
|
'tool.finished',
|
||||||
|
'interaction.required',
|
||||||
|
'usage.updated',
|
||||||
|
'turn.completed',
|
||||||
|
'error',
|
||||||
|
]);
|
||||||
|
expect(envelope).toMatchObject({
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
nativeSessionId: 'native-session-1',
|
||||||
|
turnId: 'turn-1',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
sequence: 42,
|
||||||
|
nativeEntryCursor: 'native-entry-7',
|
||||||
|
harnessId: 'pi',
|
||||||
|
selection,
|
||||||
|
event: toolStarted,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('models the complete one-response interaction lifecycle', (): void => {
|
||||||
|
const states = [
|
||||||
|
'pending',
|
||||||
|
'responded',
|
||||||
|
'cancelled',
|
||||||
|
'expired',
|
||||||
|
] as const satisfies readonly HarnessInteractionState[];
|
||||||
|
|
||||||
|
expect(states).toEqual(['pending', 'responded', 'cancelled', 'expired']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the approved adapter and conversation method signatures', (): void => {
|
||||||
|
expectTypeOf<HarnessAdapter['create']>().toEqualTypeOf<
|
||||||
|
(input: CreateHarnessSession) => Promise<HarnessSessionHandle>
|
||||||
|
>();
|
||||||
|
expectTypeOf<HarnessAdapter['resume']>().toEqualTypeOf<
|
||||||
|
(input: ResumeHarnessSession) => Promise<HarnessSessionHandle>
|
||||||
|
>();
|
||||||
|
expectTypeOf<HarnessSessionHandle['prompt']>().toEqualTypeOf<
|
||||||
|
(input: HarnessPrompt & { idempotencyKey: string }) => Promise<HarnessPromptReceipt>
|
||||||
|
>();
|
||||||
|
expectTypeOf<HarnessConversationService['attach']>().toEqualTypeOf<
|
||||||
|
(input: AttachConversation & { afterSequence?: number }) => Promise<ConversationSnapshot>
|
||||||
|
>();
|
||||||
|
expectTypeOf<HarnessConversationService['send']>().toEqualTypeOf<
|
||||||
|
(input: SendHarnessTurn & { idempotencyKey: string }) => Promise<TurnReceipt>
|
||||||
|
>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { HarnessCapability, HarnessEvent, HarnessEventEnvelope } from './events.js';
|
||||||
|
|
||||||
|
/** Server-derived actor and seat authority. Browser input must not supply these values. */
|
||||||
|
export interface HarnessActorContext {
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly seatId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact harness/provider/model tuple. These concepts must never be merged into one identifier. */
|
||||||
|
export interface HarnessSelection {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly capabilities: readonly HarnessCapability[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessInputType = 'text' | 'image';
|
||||||
|
export type HarnessAuthState = 'ready' | 'auth_required' | 'unavailable';
|
||||||
|
export type HarnessModelAvailability = 'available' | 'unavailable';
|
||||||
|
|
||||||
|
export interface HarnessCatalogEntry extends HarnessSelection {
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly reasoningCapability: boolean;
|
||||||
|
readonly thinkingLevels?: readonly string[];
|
||||||
|
readonly inputTypes: readonly HarnessInputType[];
|
||||||
|
readonly contextWindow?: number;
|
||||||
|
readonly authState: HarnessAuthState;
|
||||||
|
readonly availability: HarnessModelAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessCatalog {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly models: readonly HarnessCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHarnessSession {
|
||||||
|
readonly context: HarnessActorContext;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeHarnessSession {
|
||||||
|
readonly context: HarnessActorContext;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly nativeSessionId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessSessionState = 'starting' | 'idle' | 'busy' | 'evicted' | 'ended' | 'failed';
|
||||||
|
|
||||||
|
export interface HarnessSessionSnapshot {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly nativeSessionId: string;
|
||||||
|
/** Absent when the resumable native session has no active process. */
|
||||||
|
readonly processId?: string;
|
||||||
|
readonly seatId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
readonly state: HarnessSessionState;
|
||||||
|
readonly attachedClientIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachClient {
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessPrompt {
|
||||||
|
readonly turnId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessTurnState =
|
||||||
|
| 'prepared'
|
||||||
|
| 'dispatching'
|
||||||
|
| 'accepted'
|
||||||
|
| 'streaming'
|
||||||
|
| 'settled'
|
||||||
|
| 'failed'
|
||||||
|
| 'aborted'
|
||||||
|
| 'interrupted'
|
||||||
|
| 'outcome_unknown';
|
||||||
|
|
||||||
|
/** A successful receipt reports only the selected tuple; no substitute tuple is representable. */
|
||||||
|
export interface HarnessPromptReceipt {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly turnId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly state: HarnessTurnState;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessConfirmInteractionResponse {
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly type: 'confirm';
|
||||||
|
readonly accepted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessSelectInteractionResponse {
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly type: 'select';
|
||||||
|
readonly value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessInputInteractionResponse {
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly type: 'input';
|
||||||
|
readonly value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessEditorInteractionResponse {
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly type: 'editor';
|
||||||
|
readonly value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessCancelInteractionResponse {
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly type: 'cancel';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessInteractionResponse =
|
||||||
|
| HarnessConfirmInteractionResponse
|
||||||
|
| HarnessSelectInteractionResponse
|
||||||
|
| HarnessInputInteractionResponse
|
||||||
|
| HarnessEditorInteractionResponse
|
||||||
|
| HarnessCancelInteractionResponse;
|
||||||
|
|
||||||
|
export type HarnessCloseReason =
|
||||||
|
| 'client_request'
|
||||||
|
| 'idle_timeout'
|
||||||
|
| 'gateway_shutdown'
|
||||||
|
| 'process_crash'
|
||||||
|
| 'composition_changed'
|
||||||
|
| 'session_ended';
|
||||||
|
|
||||||
|
export interface AttachConversation {
|
||||||
|
readonly context: HarnessActorContext;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationSnapshot {
|
||||||
|
readonly session: HarnessSessionSnapshot;
|
||||||
|
readonly lastSequence: number;
|
||||||
|
/** Journal rows replayed after the caller's sequence, never best-effort socket history. */
|
||||||
|
readonly replay: readonly HarnessEventEnvelope[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetachConversation {
|
||||||
|
readonly context: HarnessActorContext;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendHarnessTurn extends HarnessPrompt {
|
||||||
|
readonly context: HarnessActorContext;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnReceipt extends HarnessPromptReceipt {}
|
||||||
|
|
||||||
|
export interface HarnessAdapter {
|
||||||
|
readonly id: string;
|
||||||
|
describe(context: HarnessActorContext): Promise<HarnessDescriptor>;
|
||||||
|
catalog(context: HarnessActorContext): Promise<HarnessCatalog>;
|
||||||
|
create(input: CreateHarnessSession): Promise<HarnessSessionHandle>;
|
||||||
|
resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessSessionHandle {
|
||||||
|
snapshot(): Promise<HarnessSessionSnapshot>;
|
||||||
|
attach(input: AttachClient): Promise<void>;
|
||||||
|
/** Removes a browser attachment; it does not terminate the process or native session. */
|
||||||
|
detach(clientId: string): Promise<void>;
|
||||||
|
prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt>;
|
||||||
|
setModel(selection: HarnessSelection): Promise<HarnessSelection>;
|
||||||
|
abort(turnId: string): Promise<void>;
|
||||||
|
respondInteraction(input: HarnessInteractionResponse): Promise<void>;
|
||||||
|
events(listener: (event: HarnessEvent) => void): () => void;
|
||||||
|
/** Stops the active process while retaining the resumable native session. */
|
||||||
|
evictProcess(reason: HarnessCloseReason): Promise<void>;
|
||||||
|
/** Explicitly and destructively ends the native session. */
|
||||||
|
endSession(reason: HarnessCloseReason): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessConversationService {
|
||||||
|
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot>;
|
||||||
|
/** Removes only the browser attachment represented by the input. */
|
||||||
|
detach(input: DetachConversation): Promise<void>;
|
||||||
|
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt>;
|
||||||
|
/** Replays persisted Gateway journal rows after the supplied monotonic sequence. */
|
||||||
|
subscribeFrom(conversationId: string, afterSequence: number): AsyncIterable<HarnessEventEnvelope>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { HarnessSelection } from './contracts.js';
|
||||||
|
|
||||||
|
export const HARNESS_ERROR_CODES = [
|
||||||
|
'auth_required',
|
||||||
|
'selection_invalid',
|
||||||
|
'catalog_unavailable',
|
||||||
|
'catalog_stale',
|
||||||
|
'model_unavailable',
|
||||||
|
'no_viable_provider',
|
||||||
|
'session_create_failed',
|
||||||
|
'session_not_found',
|
||||||
|
'resume_conflict',
|
||||||
|
'session_busy',
|
||||||
|
'auth_bundle_concurrency_unverified',
|
||||||
|
'adapter_unavailable',
|
||||||
|
'sandbox_unavailable',
|
||||||
|
'rpc_version_unsupported',
|
||||||
|
'rpc_protocol_error',
|
||||||
|
'process_exited',
|
||||||
|
'outcome_unknown',
|
||||||
|
'interaction_unsupported',
|
||||||
|
'aborted',
|
||||||
|
] as const satisfies readonly string[];
|
||||||
|
|
||||||
|
export type HarnessErrorCode = (typeof HARNESS_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
export interface HarnessErrorDto<Code extends HarnessErrorCode = HarnessErrorCode> {
|
||||||
|
readonly code: Code;
|
||||||
|
/** Safe for browser and operator-facing surfaces. */
|
||||||
|
readonly message: string;
|
||||||
|
readonly retryable: boolean;
|
||||||
|
readonly correlationId: string;
|
||||||
|
/** The requested selection; errors never report a substituted effective selection. */
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closed discriminated union over every stable harness error code. */
|
||||||
|
export type HarnessError = {
|
||||||
|
readonly [Code in HarnessErrorCode]: HarnessErrorDto<Code>;
|
||||||
|
}[HarnessErrorCode];
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { HarnessError } from './errors.js';
|
||||||
|
import type { HarnessSelection, HarnessSessionState } from './contracts.js';
|
||||||
|
|
||||||
|
export const HARNESS_CAPABILITIES = [
|
||||||
|
'modelSelection',
|
||||||
|
'thinkingLevels',
|
||||||
|
'images',
|
||||||
|
'toolEvents',
|
||||||
|
'extensionUi',
|
||||||
|
'steering',
|
||||||
|
'followUp',
|
||||||
|
'compaction',
|
||||||
|
'persistentResume',
|
||||||
|
] as const satisfies readonly string[];
|
||||||
|
|
||||||
|
export type HarnessCapability = (typeof HARNESS_CAPABILITIES)[number];
|
||||||
|
|
||||||
|
/** Durable lifecycle states; later persistence enforces one terminal response per request. */
|
||||||
|
export type HarnessInteractionState = 'pending' | 'responded' | 'cancelled' | 'expired';
|
||||||
|
export type HarnessInteractionType = 'confirm' | 'select' | 'input' | 'editor';
|
||||||
|
|
||||||
|
export interface HarnessUsage {
|
||||||
|
readonly inputTokens: number;
|
||||||
|
readonly outputTokens: number;
|
||||||
|
readonly totalTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessTurnOutcome =
|
||||||
|
| 'settled'
|
||||||
|
| 'failed'
|
||||||
|
| 'aborted'
|
||||||
|
| 'interrupted'
|
||||||
|
| 'outcome_unknown';
|
||||||
|
|
||||||
|
export interface HarnessSessionStartedEvent {
|
||||||
|
readonly type: 'session.started';
|
||||||
|
readonly state: HarnessSessionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessSessionStateEvent {
|
||||||
|
readonly type: 'session.state';
|
||||||
|
readonly state: HarnessSessionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessSessionIdentityChangedEvent {
|
||||||
|
readonly type: 'session.identity_changed';
|
||||||
|
readonly identityGeneration: number;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessTurnStartedEvent {
|
||||||
|
readonly type: 'turn.started';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessTextDeltaEvent {
|
||||||
|
readonly type: 'text.delta';
|
||||||
|
readonly text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessThinkingDeltaEvent {
|
||||||
|
readonly type: 'thinking.delta';
|
||||||
|
readonly text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessToolStartedEvent {
|
||||||
|
readonly type: 'tool.started';
|
||||||
|
readonly toolCallId: string;
|
||||||
|
readonly toolName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessToolUpdatedEvent {
|
||||||
|
readonly type: 'tool.updated';
|
||||||
|
readonly toolCallId: string;
|
||||||
|
readonly toolName: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessToolFinishedEvent {
|
||||||
|
readonly type: 'tool.finished';
|
||||||
|
readonly toolCallId: string;
|
||||||
|
readonly toolName: string;
|
||||||
|
readonly isError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessInteractionRequiredEvent {
|
||||||
|
readonly type: 'interaction.required';
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly interactionType: HarnessInteractionType;
|
||||||
|
readonly state: HarnessInteractionState;
|
||||||
|
readonly prompt: string;
|
||||||
|
readonly options?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessUsageUpdatedEvent {
|
||||||
|
readonly type: 'usage.updated';
|
||||||
|
readonly usage: HarnessUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessTurnCompletedEvent {
|
||||||
|
readonly type: 'turn.completed';
|
||||||
|
readonly outcome: HarnessTurnOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessErrorEvent {
|
||||||
|
readonly type: 'error';
|
||||||
|
readonly error: HarnessError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessEvent =
|
||||||
|
| HarnessSessionStartedEvent
|
||||||
|
| HarnessSessionStateEvent
|
||||||
|
| HarnessSessionIdentityChangedEvent
|
||||||
|
| HarnessTurnStartedEvent
|
||||||
|
| HarnessTextDeltaEvent
|
||||||
|
| HarnessThinkingDeltaEvent
|
||||||
|
| HarnessToolStartedEvent
|
||||||
|
| HarnessToolUpdatedEvent
|
||||||
|
| HarnessToolFinishedEvent
|
||||||
|
| HarnessInteractionRequiredEvent
|
||||||
|
| HarnessUsageUpdatedEvent
|
||||||
|
| HarnessTurnCompletedEvent
|
||||||
|
| HarnessErrorEvent;
|
||||||
|
|
||||||
|
/** Persisted normalized event plus Gateway-owned ordering and native reconciliation metadata. */
|
||||||
|
export interface HarnessEventEnvelope {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly nativeSessionId: string;
|
||||||
|
readonly turnId?: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
/** Monotonic Gateway journal sequence within the conversation. */
|
||||||
|
readonly sequence: number;
|
||||||
|
/** Native session-entry cursor when the harness provides one. */
|
||||||
|
readonly nativeEntryCursor?: string;
|
||||||
|
readonly occurredAt: string;
|
||||||
|
readonly harnessId: string;
|
||||||
|
/** Exact effective selected provider/model tuple; no alternate success selection is exposed. */
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
readonly event: HarnessEvent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './contracts.js';
|
||||||
|
export * from './events.js';
|
||||||
|
export * from './errors.js';
|
||||||
@@ -8,3 +8,4 @@ export * from './routing/index.js';
|
|||||||
export * from './commands/index.js';
|
export * from './commands/index.js';
|
||||||
export * from './federation/index.js';
|
export * from './federation/index.js';
|
||||||
export * from './reflection/index.js';
|
export * from './reflection/index.js';
|
||||||
|
export * from './harness/index.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user