From ddf8616716f2304c5a27964a79412f0c002ba909 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Tue, 11 Aug 2026 18:32:39 -0500 Subject: [PATCH] feat(types): add generic harness contracts --- packages/types/src/harness/contracts.spec.ts | 317 +++++++++++++++++++ packages/types/src/harness/contracts.ts | 204 ++++++++++++ packages/types/src/harness/errors.ts | 40 +++ packages/types/src/harness/events.ts | 139 ++++++++ packages/types/src/harness/index.ts | 3 + packages/types/src/index.ts | 1 + 6 files changed, 704 insertions(+) create mode 100644 packages/types/src/harness/contracts.spec.ts create mode 100644 packages/types/src/harness/contracts.ts create mode 100644 packages/types/src/harness/errors.ts create mode 100644 packages/types/src/harness/events.ts create mode 100644 packages/types/src/harness/index.ts diff --git a/packages/types/src/harness/contracts.spec.ts b/packages/types/src/harness/contracts.spec.ts new file mode 100644 index 00000000..582c4115 --- /dev/null +++ b/packages/types/src/harness/contracts.spec.ts @@ -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().toEqualTypeOf< + (input: CreateHarnessSession) => Promise + >(); + expectTypeOf().toEqualTypeOf< + (input: ResumeHarnessSession) => Promise + >(); + expectTypeOf().toEqualTypeOf< + (input: HarnessPrompt & { idempotencyKey: string }) => Promise + >(); + expectTypeOf().toEqualTypeOf< + (input: AttachConversation & { afterSequence?: number }) => Promise + >(); + expectTypeOf().toEqualTypeOf< + (input: SendHarnessTurn & { idempotencyKey: string }) => Promise + >(); + }); +}); diff --git a/packages/types/src/harness/contracts.ts b/packages/types/src/harness/contracts.ts new file mode 100644 index 00000000..cdfa1205 --- /dev/null +++ b/packages/types/src/harness/contracts.ts @@ -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; + catalog(context: HarnessActorContext): Promise; + create(input: CreateHarnessSession): Promise; + resume(input: ResumeHarnessSession): Promise; +} + +export interface HarnessSessionHandle { + snapshot(): Promise; + attach(input: AttachClient): Promise; + /** Removes a browser attachment; it does not terminate the process or native session. */ + detach(clientId: string): Promise; + prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise; + setModel(selection: HarnessSelection): Promise; + abort(turnId: string): Promise; + respondInteraction(input: HarnessInteractionResponse): Promise; + events(listener: (event: HarnessEvent) => void): () => void; + /** Stops the active process while retaining the resumable native session. */ + evictProcess(reason: HarnessCloseReason): Promise; + /** Explicitly and destructively ends the native session. */ + endSession(reason: HarnessCloseReason): Promise; +} + +export interface HarnessConversationService { + attach(input: AttachConversation & { afterSequence?: number }): Promise; + /** Removes only the browser attachment represented by the input. */ + detach(input: DetachConversation): Promise; + send(input: SendHarnessTurn & { idempotencyKey: string }): Promise; + /** Replays persisted Gateway journal rows after the supplied monotonic sequence. */ + subscribeFrom(conversationId: string, afterSequence: number): AsyncIterable; +} diff --git a/packages/types/src/harness/errors.ts b/packages/types/src/harness/errors.ts new file mode 100644 index 00000000..0109f42b --- /dev/null +++ b/packages/types/src/harness/errors.ts @@ -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 { + 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; +}[HarnessErrorCode]; diff --git a/packages/types/src/harness/events.ts b/packages/types/src/harness/events.ts new file mode 100644 index 00000000..d15bed46 --- /dev/null +++ b/packages/types/src/harness/events.ts @@ -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; +} diff --git a/packages/types/src/harness/index.ts b/packages/types/src/harness/index.ts new file mode 100644 index 00000000..3f550f5c --- /dev/null +++ b/packages/types/src/harness/index.ts @@ -0,0 +1,3 @@ +export * from './contracts.js'; +export * from './events.js'; +export * from './errors.js'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3b2ab893..6f4fbdb1 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,3 +8,4 @@ export * from './routing/index.js'; export * from './commands/index.js'; export * from './federation/index.js'; export * from './reflection/index.js'; +export * from './harness/index.js';