refactor(chat): route browser chat through one runtime (P3 Slice-Zero Task 5) (#1172)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
This commit was merged in pull request #1172.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,920 @@
|
||||
import 'reflect-metadata';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { HarnessModule } from '../harness/harness.module.js';
|
||||
import { ChatModule } from './chat.module.js';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import {
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
HARNESS_REGISTRY,
|
||||
type HarnessConversationServiceBinding,
|
||||
} from '../harness/harness.tokens.js';
|
||||
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||
import {
|
||||
ChatRuntimeUnavailableError,
|
||||
ownConversation,
|
||||
type ChatRuntime,
|
||||
type ChatRuntimeMode,
|
||||
type LegacyEmbeddedChatPort,
|
||||
type LegacyRuntimeStream,
|
||||
type LegacySessionPresentation,
|
||||
type LegacySocketTurnLease,
|
||||
type OwnedConversationContext,
|
||||
} from './chat-runtime.js';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
|
||||
/**
|
||||
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
|
||||
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
|
||||
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
|
||||
* unimplemented stub, so every behavioural assertion below fails until Step Three.
|
||||
*/
|
||||
|
||||
const embedded: ChatRuntime = { kind: 'embedded' };
|
||||
const harness: ChatRuntime = { kind: 'harness' };
|
||||
|
||||
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
|
||||
const boundConversationService = {
|
||||
attach: () => Promise.reject(new Error('unused')),
|
||||
detach: () => Promise.reject(new Error('unused')),
|
||||
send: () => Promise.reject(new Error('unused')),
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
for (const id of adapterIds) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function buildRouter(
|
||||
mode: ChatRuntimeMode,
|
||||
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||
): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a module that was deliberately driven to a fail-closed init.
|
||||
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
|
||||
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
|
||||
* startup error, this time into teardown. Each caller here has already captured and asserted that
|
||||
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
|
||||
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
|
||||
*/
|
||||
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
|
||||
await moduleRef.close().catch((err: unknown) => {
|
||||
if (err instanceof ChatRuntimeUnavailableError) return;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChatRuntimeRouter', () => {
|
||||
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).not.toThrow();
|
||||
expect(router.active).toBe(harness);
|
||||
expect(router.active.kind).toBe('harness');
|
||||
});
|
||||
|
||||
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
|
||||
// Empty registry + unavailable service: legacy must ignore both and still start.
|
||||
const router = buildRouter('legacy', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).not.toThrow();
|
||||
expect(router.active).toBe(embedded);
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
});
|
||||
|
||||
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||
// A failed pi-rpc init must not silently expose the embedded runtime.
|
||||
expect(() => router.active).toThrow();
|
||||
let leaked: ChatRuntime | undefined;
|
||||
try {
|
||||
leaked = router.active;
|
||||
} catch {
|
||||
leaked = undefined;
|
||||
}
|
||||
expect(leaked).not.toBe(embedded);
|
||||
});
|
||||
|
||||
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw');
|
||||
} catch (err) {
|
||||
const message = (err as ChatRuntimeUnavailableError).message;
|
||||
expect(message).toBe(
|
||||
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||
);
|
||||
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
|
||||
*
|
||||
* The unit suite above constructs the router but never invokes a legacy port operation, so the
|
||||
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
|
||||
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
|
||||
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
|
||||
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
|
||||
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
|
||||
*
|
||||
* For each operation:
|
||||
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
|
||||
* result AND that the embedded stub was touched zero times (no effects);
|
||||
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
|
||||
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
|
||||
* not vacuously true because the stub could never be called.
|
||||
*
|
||||
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
|
||||
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
|
||||
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
|
||||
*/
|
||||
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
|
||||
const RUNTIME_UNSUPPORTED = {
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
retryable: false,
|
||||
} as const;
|
||||
|
||||
const PRESENTATION: LegacySessionPresentation = {
|
||||
provider: 'embedded-provider',
|
||||
modelId: 'embedded-model',
|
||||
thinkingLevel: 'low',
|
||||
availableThinkingLevels: ['low', 'high'],
|
||||
};
|
||||
|
||||
const stream: LegacyRuntimeStream = {
|
||||
channelId: 'websocket:test-socket',
|
||||
onEvent: () => {},
|
||||
};
|
||||
|
||||
const ctx = (): OwnedConversationContext =>
|
||||
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
|
||||
|
||||
/**
|
||||
* Per-operation invocation counters with declared keys (not an index signature) so each
|
||||
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
|
||||
*/
|
||||
type LegacyPortCallCounts = {
|
||||
completeLegacyRestTurn: number;
|
||||
prepareLegacySocketTurn: number;
|
||||
setLegacyThinking: number;
|
||||
abortLegacyTurn: number;
|
||||
applyLegacyModelOverride: number;
|
||||
readLegacySessionPresentation: number;
|
||||
dispatchVerifiedDiscordIngress: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* An embedded port that records every invocation and returns a distinguishable `ok:true`
|
||||
* value per operation. If a router op reaches it (its guard removed), both the recorded call
|
||||
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
|
||||
*/
|
||||
function recordingEmbeddedPort(): {
|
||||
port: ChatRuntime & LegacyEmbeddedChatPort;
|
||||
calls: LegacyPortCallCounts;
|
||||
} {
|
||||
const calls: LegacyPortCallCounts = {
|
||||
completeLegacyRestTurn: 0,
|
||||
prepareLegacySocketTurn: 0,
|
||||
setLegacyThinking: 0,
|
||||
abortLegacyTurn: 0,
|
||||
applyLegacyModelOverride: 0,
|
||||
readLegacySessionPresentation: 0,
|
||||
dispatchVerifiedDiscordIngress: 0,
|
||||
};
|
||||
const lease: LegacySocketTurnLease = {
|
||||
presentation: PRESENTATION,
|
||||
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||
dispose: () => Promise.resolve(),
|
||||
};
|
||||
const port: ChatRuntime & LegacyEmbeddedChatPort = {
|
||||
kind: 'embedded',
|
||||
completeLegacyRestTurn: () => {
|
||||
calls.completeLegacyRestTurn += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
|
||||
});
|
||||
},
|
||||
prepareLegacySocketTurn: () => {
|
||||
calls.prepareLegacySocketTurn += 1;
|
||||
return Promise.resolve({ ok: true, value: lease });
|
||||
},
|
||||
setLegacyThinking: () => {
|
||||
calls.setLegacyThinking += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
abortLegacyTurn: () => {
|
||||
calls.abortLegacyTurn += 1;
|
||||
return Promise.resolve({ ok: true, value: undefined });
|
||||
},
|
||||
applyLegacyModelOverride: () => {
|
||||
calls.applyLegacyModelOverride += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
readLegacySessionPresentation: () => {
|
||||
calls.readLegacySessionPresentation += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
dispatchVerifiedDiscordIngress: () => {
|
||||
calls.dispatchVerifiedDiscordIngress += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
value: {
|
||||
presentation: PRESENTATION,
|
||||
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||
dispose: () => Promise.resolve(),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
return { port, calls };
|
||||
}
|
||||
|
||||
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(
|
||||
registryWith(['pi']),
|
||||
boundConversationService,
|
||||
port,
|
||||
harness,
|
||||
'pi-rpc',
|
||||
);
|
||||
}
|
||||
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(
|
||||
registryWith([]),
|
||||
boundConversationService,
|
||||
port,
|
||||
harness,
|
||||
'legacy',
|
||||
);
|
||||
}
|
||||
|
||||
// completeLegacyRestTurn ---------------------------------------------------
|
||||
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.completeLegacyRestTurn).toBe(0);
|
||||
});
|
||||
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.completeLegacyRestTurn).toBe(1);
|
||||
});
|
||||
|
||||
// prepareLegacySocketTurn --------------------------------------------------
|
||||
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).prepareLegacySocketTurn(
|
||||
ctx(),
|
||||
{ content: 'hello' },
|
||||
stream,
|
||||
);
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.prepareLegacySocketTurn).toBe(0);
|
||||
});
|
||||
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).prepareLegacySocketTurn(
|
||||
ctx(),
|
||||
{ content: 'hello' },
|
||||
stream,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.prepareLegacySocketTurn).toBe(1);
|
||||
});
|
||||
|
||||
// setLegacyThinking (sync) -------------------------------------------------
|
||||
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.setLegacyThinking).toBe(0);
|
||||
});
|
||||
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.setLegacyThinking).toBe(1);
|
||||
});
|
||||
|
||||
// abortLegacyTurn ----------------------------------------------------------
|
||||
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).abortLegacyTurn(ctx());
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.abortLegacyTurn).toBe(0);
|
||||
});
|
||||
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).abortLegacyTurn(ctx());
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.abortLegacyTurn).toBe(1);
|
||||
});
|
||||
|
||||
// applyLegacyModelOverride (sync) ------------------------------------------
|
||||
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.applyLegacyModelOverride).toBe(0);
|
||||
});
|
||||
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.applyLegacyModelOverride).toBe(1);
|
||||
});
|
||||
|
||||
// readLegacySessionPresentation (sync) -------------------------------------
|
||||
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).readLegacySessionPresentation(ctx());
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.readLegacySessionPresentation).toBe(0);
|
||||
});
|
||||
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.readLegacySessionPresentation).toBe(1);
|
||||
});
|
||||
|
||||
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
|
||||
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const discordCtx = ctx() as unknown as Parameters<
|
||||
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
|
||||
>[0];
|
||||
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
|
||||
*
|
||||
* The unit suite above constructs the router directly. This group drives the SAME contract
|
||||
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
|
||||
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
|
||||
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
|
||||
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
|
||||
* `onModuleInit` throws a generic Error, so:
|
||||
* - readiness cases fail because the graph never comes up (init rejects), and
|
||||
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
|
||||
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
|
||||
* "throws anything" cannot mask these greens.
|
||||
* The router is NOT wired into a production module yet, so it is provided here via a factory
|
||||
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
|
||||
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
|
||||
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
|
||||
*/
|
||||
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
|
||||
async function bootRouterGraph(
|
||||
mode: ChatRuntimeMode,
|
||||
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||
) {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [HarnessModule],
|
||||
providers: [
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: (registry: HarnessRegistry) =>
|
||||
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
|
||||
inject: [HARNESS_REGISTRY],
|
||||
},
|
||||
],
|
||||
})
|
||||
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
|
||||
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
|
||||
// group 1 asserts against the genuine production HarnessRegistry.
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
// Resolve the production registry singleton and register the requested adapters ON IT, so
|
||||
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
for (const id of opts.adapters) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return moduleRef;
|
||||
}
|
||||
|
||||
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
|
||||
moduleRef.init().then(
|
||||
() => new Error('module init resolved but the contract requires it to reject'),
|
||||
(err: unknown) => err,
|
||||
);
|
||||
|
||||
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active).toBe(harness);
|
||||
expect(router.active.kind).toBe('harness');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
|
||||
const moduleRef = await bootRouterGraph('legacy', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
// Defense-in-depth: the production module wires the genuine registry, empty by default —
|
||||
// guards against a test-double registry silently satisfying the readiness check.
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||
expect(registry.list()).toHaveLength(0);
|
||||
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active).toBe(embedded);
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
const message = (err as ChatRuntimeUnavailableError).message;
|
||||
expect(message).toBe(
|
||||
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||
);
|
||||
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
|
||||
*
|
||||
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
|
||||
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
|
||||
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
|
||||
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
|
||||
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
|
||||
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
|
||||
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
|
||||
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
|
||||
*
|
||||
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
|
||||
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
|
||||
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
|
||||
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
|
||||
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
|
||||
* of the two covers "the router is wired through ChatModule to the real registry" without the
|
||||
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
|
||||
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
|
||||
*/
|
||||
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
|
||||
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
|
||||
const resolveImport = (imp: unknown): unknown =>
|
||||
imp &&
|
||||
typeof imp === 'object' &&
|
||||
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
|
||||
? (imp as { forwardRef: () => unknown }).forwardRef()
|
||||
: imp;
|
||||
|
||||
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
|
||||
const providerToken = (provider: unknown): unknown =>
|
||||
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
||||
|
||||
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
|
||||
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
|
||||
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
|
||||
});
|
||||
|
||||
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
|
||||
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
|
||||
expect(imports.map(resolveImport)).toContain(HarnessModule);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
|
||||
*
|
||||
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
|
||||
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
|
||||
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
|
||||
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
|
||||
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
|
||||
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
|
||||
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
|
||||
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
|
||||
* with an inert value;
|
||||
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
|
||||
* - the HTTP-only `AuthGuard` is stubbed.
|
||||
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
|
||||
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
|
||||
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
|
||||
*
|
||||
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
|
||||
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
|
||||
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
|
||||
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
|
||||
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
|
||||
* conversation-service token (defaulting to the unavailable sentinel).
|
||||
*/
|
||||
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
|
||||
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
|
||||
@Module({})
|
||||
class EmptyCommandsModule {}
|
||||
|
||||
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
|
||||
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: AgentService, useValue: {} }],
|
||||
exports: [AgentService],
|
||||
})
|
||||
class LegacyControllerDepsModule {}
|
||||
|
||||
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
|
||||
});
|
||||
|
||||
/**
|
||||
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
|
||||
* genuine production env contract before providers instantiate. The optional overrides replace
|
||||
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
|
||||
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
|
||||
* the graph — which is exactly why the router-retrieval assertions go red).
|
||||
*/
|
||||
async function bootChatModule(
|
||||
mode: ChatRuntimeMode,
|
||||
overrides: {
|
||||
registryAdapters?: readonly string[];
|
||||
conversationService?: HarnessConversationServiceBinding;
|
||||
} = {},
|
||||
): Promise<TestingModule> {
|
||||
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
else delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
|
||||
let builder = Test.createTestingModule({
|
||||
imports: [LegacyControllerDepsModule, ChatModule],
|
||||
})
|
||||
.overrideModule(CommandsModule)
|
||||
.useModule(EmptyCommandsModule)
|
||||
.overrideProvider(ChatGateway)
|
||||
.useValue({})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true });
|
||||
|
||||
if (overrides.registryAdapters) {
|
||||
builder = builder
|
||||
.overrideProvider(HARNESS_REGISTRY)
|
||||
.useValue(registryWith(overrides.registryAdapters));
|
||||
}
|
||||
if (overrides.conversationService !== undefined) {
|
||||
builder = builder
|
||||
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
|
||||
.useValue(overrides.conversationService);
|
||||
}
|
||||
return builder.compile();
|
||||
}
|
||||
|
||||
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||
const initError = (moduleRef: TestingModule): Promise<unknown> =>
|
||||
moduleRef.init().then(
|
||||
() => new Error('module init resolved but the contract requires it to reject'),
|
||||
(err: unknown) => err,
|
||||
);
|
||||
|
||||
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
|
||||
const moduleRef = await bootChatModule('legacy');
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||
expect(registry.list()).toHaveLength(0);
|
||||
|
||||
const service = moduleRef.get<HarnessConversationServiceBinding>(
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
{
|
||||
strict: false,
|
||||
},
|
||||
);
|
||||
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc');
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc', {
|
||||
registryAdapters: ['pi'],
|
||||
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc', {
|
||||
registryAdapters: ['pi'],
|
||||
conversationService: boundConversationService,
|
||||
});
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('harness');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
|
||||
*
|
||||
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
|
||||
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
|
||||
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
|
||||
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
|
||||
* the disk/network leaves:
|
||||
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
|
||||
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
|
||||
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
|
||||
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
|
||||
* system-rule count — the fake reports rules already present so the seed insert is skipped),
|
||||
* opening no real database;
|
||||
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
|
||||
* storage/auth/log backend is contacted.
|
||||
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
|
||||
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
|
||||
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
|
||||
*
|
||||
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
|
||||
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
|
||||
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
|
||||
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
|
||||
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
|
||||
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
|
||||
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
|
||||
* effect.
|
||||
*/
|
||||
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
|
||||
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
|
||||
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
|
||||
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
|
||||
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
|
||||
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
|
||||
const fakeDb = {
|
||||
$client: { exec: async (): Promise<void> => {} },
|
||||
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: async (): Promise<void> => {} }),
|
||||
};
|
||||
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
|
||||
const fakeStorageAdapter = {
|
||||
name: 'fake',
|
||||
migrate: async (): Promise<void> => {},
|
||||
close: async (): Promise<void> => {},
|
||||
};
|
||||
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
|
||||
const fakeProviderService = {
|
||||
onModuleInit: async (): Promise<void> => {},
|
||||
onModuleDestroy: (): void => {},
|
||||
getRegistry: () => ({
|
||||
getAvailable: () => [],
|
||||
getAll: () => [],
|
||||
find: () => undefined,
|
||||
}),
|
||||
getDefaultModel: () => undefined,
|
||||
listAvailableModels: () => [],
|
||||
listProviders: () => [],
|
||||
getAdapter: () => undefined,
|
||||
getProvidersHealth: () => [],
|
||||
};
|
||||
const fakeBrain = { conversations: {}, agents: {} };
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
let moduleRef: TestingModule;
|
||||
let envSnapshot: Record<string, string | undefined>;
|
||||
|
||||
beforeAll(async () => {
|
||||
envSnapshot = { ...process.env };
|
||||
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['DISCORD_BOT_TOKEN'];
|
||||
delete process.env['TELEGRAM_BOT_TOKEN'];
|
||||
delete process.env['MCP_SERVERS'];
|
||||
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
|
||||
process.env['MOSAIC_STORAGE_TIER'] = 'local';
|
||||
|
||||
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
|
||||
.overrideProvider('DB_HANDLE')
|
||||
.useValue(fakeDbHandle)
|
||||
.overrideProvider('DB')
|
||||
.useValue(fakeDb)
|
||||
.overrideProvider('STORAGE_ADAPTER')
|
||||
.useValue(fakeStorageAdapter)
|
||||
.overrideProvider('AUTH')
|
||||
.useValue({})
|
||||
.overrideProvider('BRAIN')
|
||||
.useValue(fakeBrain)
|
||||
.overrideProvider('LOG_SERVICE')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY_ADAPTER')
|
||||
.useValue({})
|
||||
.overrideProvider(ProviderService)
|
||||
.useValue(fakeProviderService)
|
||||
.compile();
|
||||
|
||||
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
|
||||
await moduleRef.init();
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
if (moduleRef) await moduleRef.close();
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in envSnapshot)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(envSnapshot)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
|
||||
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
|
||||
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
|
||||
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
expect(typeof gateway.broadcastReload).toBe('function');
|
||||
expect(typeof gateway.getModelOverride).toBe('function');
|
||||
expect(typeof gateway.setModelOverride).toBe('function');
|
||||
expect(typeof gateway.broadcastSessionInfo).toBe('function');
|
||||
});
|
||||
|
||||
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
|
||||
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
|
||||
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
|
||||
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import {
|
||||
isHarnessConversationServiceAvailable,
|
||||
type HarnessConversationServiceBinding,
|
||||
} from '../harness/harness.tokens.js';
|
||||
import type {
|
||||
ChatRuntime,
|
||||
ChatRuntimeMode,
|
||||
LegacyBrowserMessagePayload,
|
||||
LegacyEmbeddedChatPort,
|
||||
LegacyRuntimeResult,
|
||||
LegacyRuntimeStream,
|
||||
LegacySessionPresentation,
|
||||
LegacySocketTurnLease,
|
||||
OwnedConversationContext,
|
||||
VerifiedDiscordIngressContext,
|
||||
VerifiedDiscordTurnLease,
|
||||
} from './chat-runtime.js';
|
||||
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
|
||||
|
||||
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
|
||||
const RUNTIME_UNSUPPORTED = {
|
||||
ok: false as const,
|
||||
code: 'runtime_unsupported' as const,
|
||||
retryable: false as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the one live {@link ChatRuntime} for this process and enforces the
|
||||
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
|
||||
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
|
||||
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
|
||||
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
|
||||
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
|
||||
private readonly mode: ChatRuntimeMode;
|
||||
|
||||
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
|
||||
private resolved: ChatRuntime | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly harnessRegistry: HarnessRegistry,
|
||||
private readonly conversationService: HarnessConversationServiceBinding,
|
||||
private readonly embedded: ChatRuntime,
|
||||
private readonly harness: ChatRuntime,
|
||||
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
|
||||
) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.mode === 'legacy') {
|
||||
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
|
||||
this.resolved = this.embedded;
|
||||
return;
|
||||
}
|
||||
|
||||
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
|
||||
if (!this.harnessRegistry.has('pi')) {
|
||||
this.resolved = undefined;
|
||||
throw new ChatRuntimeUnavailableError('adapter_unavailable');
|
||||
}
|
||||
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
|
||||
this.resolved = undefined;
|
||||
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
|
||||
}
|
||||
|
||||
this.resolved = this.harness;
|
||||
}
|
||||
|
||||
get active(): ChatRuntime {
|
||||
if (this.resolved === undefined) {
|
||||
// Reached only if init has not run or failed closed; never expose a runtime here.
|
||||
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
|
||||
}
|
||||
return this.resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
|
||||
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
|
||||
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
|
||||
*/
|
||||
get runtimeMode(): ChatRuntimeMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
|
||||
* verified-Discord op in both modes), where the injected runtime is always a real
|
||||
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
|
||||
* but never invokes a port op, so this narrowing is never exercised against the stub.
|
||||
*/
|
||||
private get embeddedPort(): LegacyEmbeddedChatPort {
|
||||
return this.embedded as unknown as LegacyEmbeddedChatPort;
|
||||
}
|
||||
|
||||
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
|
||||
|
||||
completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.completeLegacyRestTurn(context, input);
|
||||
}
|
||||
|
||||
prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
|
||||
}
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.setLegacyThinking(context, level);
|
||||
}
|
||||
|
||||
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.abortLegacyTurn(context);
|
||||
}
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
|
||||
}
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.readLegacySessionPresentation(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
|
||||
* never reaches the harness or routing-engine selection. It is reached only through a
|
||||
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
|
||||
*/
|
||||
dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
|
||||
|
||||
/**
|
||||
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
|
||||
*
|
||||
* Exactly one runtime is live per process. There is no union that lets a
|
||||
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
|
||||
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
|
||||
*/
|
||||
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
|
||||
|
||||
export type ChatRuntimeKind = 'embedded' | 'harness';
|
||||
|
||||
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
|
||||
export interface ChatRuntime {
|
||||
readonly kind: ChatRuntimeKind;
|
||||
}
|
||||
|
||||
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
|
||||
export type ChatRuntimeUnavailableReason =
|
||||
| 'adapter_unavailable'
|
||||
| 'conversation_service_unavailable';
|
||||
|
||||
/**
|
||||
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
|
||||
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
|
||||
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
|
||||
*/
|
||||
export class ChatRuntimeUnavailableError extends Error {
|
||||
readonly code = 'runtime_unsupported' as const;
|
||||
readonly reason: ChatRuntimeUnavailableReason;
|
||||
|
||||
constructor(reason: ChatRuntimeUnavailableReason) {
|
||||
super(
|
||||
reason === 'adapter_unavailable'
|
||||
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
|
||||
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
|
||||
);
|
||||
this.name = 'ChatRuntimeUnavailableError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
||||
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
||||
*/
|
||||
export function resolveChatRuntimeMode(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): ChatRuntimeMode {
|
||||
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transitional embedded chat port (Task Five).
|
||||
//
|
||||
// The legacy embedded browser behaviour is moved behind this exact interface so
|
||||
// neither the controller nor the gateway retains AgentService, RoutingEngine,
|
||||
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
|
||||
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
|
||||
// operations and returns `runtime_unsupported` before touching Embedded for legacy
|
||||
// browser operations when the mode is `pi-rpc`.
|
||||
//
|
||||
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
|
||||
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
|
||||
// transport/trust capabilities — there is deliberately no generic
|
||||
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
|
||||
* DTOs are never structurally assignable to it. The factory that mints one may be called
|
||||
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
|
||||
*/
|
||||
declare const ownedConversationContextBrand: unique symbol;
|
||||
|
||||
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
|
||||
export interface OwnedConversationContext {
|
||||
readonly [ownedConversationContextBrand]: true;
|
||||
readonly conversationId: string;
|
||||
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
|
||||
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
|
||||
* failures are total results and never throw.
|
||||
*/
|
||||
export type LegacyRuntimeFailure =
|
||||
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly code: 'thinking_level_invalid';
|
||||
readonly retryable: false;
|
||||
readonly availableThinkingLevels: readonly string[];
|
||||
}
|
||||
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
|
||||
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
|
||||
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
|
||||
|
||||
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
|
||||
export type LegacyRuntimeResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| LegacyRuntimeFailure;
|
||||
|
||||
/** User-facing session projection. Carries no session object, handle, or credential path. */
|
||||
export interface LegacySessionPresentation {
|
||||
readonly provider: string;
|
||||
readonly modelId: string;
|
||||
readonly thinkingLevel: string;
|
||||
readonly availableThinkingLevels: readonly string[];
|
||||
readonly agentName?: string;
|
||||
readonly routingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
|
||||
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
|
||||
export interface LegacyUsage {
|
||||
readonly provider: string;
|
||||
readonly modelId: string;
|
||||
readonly thinkingLevel: string;
|
||||
readonly tokens: Readonly<{
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
}>;
|
||||
readonly cost: number;
|
||||
readonly context: Readonly<{ percent: number | null; window: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
|
||||
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
|
||||
*/
|
||||
export type LegacyRuntimeEvent =
|
||||
| { readonly type: 'started' }
|
||||
| { readonly type: 'text_delta'; readonly text: string }
|
||||
| { readonly type: 'thinking_delta'; readonly text: string }
|
||||
| {
|
||||
readonly type: 'tool_started';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
}
|
||||
| {
|
||||
readonly type: 'tool_finished';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
readonly isError: boolean;
|
||||
}
|
||||
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
|
||||
|
||||
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
|
||||
export interface LegacyBrowserMessagePayload {
|
||||
readonly content: string;
|
||||
readonly provider?: string;
|
||||
readonly modelId?: string;
|
||||
readonly agentId?: string;
|
||||
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||
}
|
||||
|
||||
/** A prepared-but-not-yet-dispatched legacy socket turn. */
|
||||
export interface LegacySocketTurnLease {
|
||||
readonly presentation: LegacySessionPresentation;
|
||||
/**
|
||||
* Atomically one-shot and scope-rechecking. A second call returns
|
||||
* `turn_already_dispatched` and performs zero prompt/tool effects.
|
||||
*/
|
||||
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
|
||||
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
|
||||
* forced-scope, and attachment-normalization checks.
|
||||
*/
|
||||
declare const verifiedDiscordIngressContextBrand: unique symbol;
|
||||
|
||||
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
|
||||
export interface VerifiedDiscordIngressContext {
|
||||
readonly [verifiedDiscordIngressContextBrand]: true;
|
||||
readonly conversationId: string;
|
||||
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
|
||||
readonly content: string;
|
||||
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||
readonly correlationId: string;
|
||||
readonly discordMessageId: string;
|
||||
readonly discordUserId: string;
|
||||
}
|
||||
|
||||
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
|
||||
export interface VerifiedDiscordTurnLease {
|
||||
readonly presentation: LegacySessionPresentation;
|
||||
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Server-owned egress projection the runtime pushes normalized events into. */
|
||||
export interface LegacyRuntimeStream {
|
||||
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
|
||||
readonly channelId: string;
|
||||
onEvent(event: LegacyRuntimeEvent): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
|
||||
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
|
||||
* browser operations under `pi-rpc`.
|
||||
*/
|
||||
export interface LegacyEmbeddedChatPort {
|
||||
completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
>;
|
||||
|
||||
prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
|
||||
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
|
||||
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
|
||||
*/
|
||||
export function ownConversation(
|
||||
conversationId: string,
|
||||
scope: Readonly<{ userId: string; tenantId: string }>,
|
||||
): OwnedConversationContext {
|
||||
return { conversationId, scope } as unknown as OwnedConversationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
|
||||
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
|
||||
* configured-agent, forced-scope, attachment normalization) before calling this.
|
||||
*/
|
||||
export function verifyDiscordIngress(
|
||||
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
|
||||
): VerifiedDiscordIngressContext {
|
||||
return { ...fields } as unknown as VerifiedDiscordIngressContext;
|
||||
}
|
||||
@@ -3,21 +3,20 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatRequestDto } from './chat.dto.js';
|
||||
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||
import { ownConversation } from './chat-runtime.js';
|
||||
import type { LegacyRuntimeFailure } from './chat-runtime.js';
|
||||
|
||||
interface ChatResponse {
|
||||
conversationId: string;
|
||||
@@ -29,7 +28,7 @@ interface ChatResponse {
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
constructor(private readonly runtime: ChatRuntimeRouter) {}
|
||||
|
||||
@Post()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@@ -40,68 +39,38 @@ export class ChatController {
|
||||
const conversationId = body.conversationId ?? uuid();
|
||||
const scope = scopeFromUser(user);
|
||||
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||
if (!agentSession) {
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
this.logger.error(
|
||||
`Session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
||||
|
||||
let responseText = '';
|
||||
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
|
||||
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
|
||||
const result = await this.runtime.completeLegacyRestTurn(
|
||||
ownConversation(conversationId, scope),
|
||||
{ content: body.content },
|
||||
);
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, 120_000);
|
||||
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, body.content, scope);
|
||||
await done;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('timed out')) {
|
||||
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||
}
|
||||
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
|
||||
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
if (result.ok) {
|
||||
return { conversationId, text: result.value.text };
|
||||
}
|
||||
|
||||
return { conversationId, text: responseText };
|
||||
throw this.toHttpException(result, conversationId);
|
||||
}
|
||||
|
||||
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
|
||||
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
|
||||
switch (failure.code) {
|
||||
case 'conversation_unavailable':
|
||||
return new NotFoundException('Session not found');
|
||||
case 'request_invalid':
|
||||
case 'thinking_level_invalid':
|
||||
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
|
||||
case 'timeout':
|
||||
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||
case 'runtime_unsupported':
|
||||
case 'runtime_unavailable':
|
||||
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
default:
|
||||
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
|
||||
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ChatRequestDto {
|
||||
@IsOptional()
|
||||
@@ -37,3 +46,56 @@ export class ChatSocketMessageDto {
|
||||
/** Validated channel attachment references; binary content is not embedded. */
|
||||
attachments?: readonly ChannelAttachmentDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple.
|
||||
*
|
||||
* Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra
|
||||
* field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id
|
||||
* fails `@IsString` (undefined is not a string) rather than silently passing.
|
||||
*/
|
||||
export class HarnessTurnSelectionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
harnessId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
providerId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
modelId!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`.
|
||||
*
|
||||
* Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`:
|
||||
* a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only
|
||||
* collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an
|
||||
* explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata
|
||||
* `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other
|
||||
* authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key.
|
||||
*/
|
||||
export class HarnessTurnSendDto {
|
||||
@IsUUID()
|
||||
conversationId!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(10_000)
|
||||
content!: string;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => HarnessTurnSelectionDto)
|
||||
selection!: HarnessTurnSelectionDto;
|
||||
|
||||
@IsUUID('4')
|
||||
idempotencyKey!: string;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,31 @@ const payload: SlashCommandPayload = {
|
||||
approvalId: 'approval-1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Task 5 fence (F, existing control): gateway-owned command authorization/approval must
|
||||
* cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the
|
||||
* former direct `AgentService` slot) so any accidental chat-runtime resolution throws
|
||||
* loudly instead of silently passing. Because execute/approval run entirely through the
|
||||
* command executor dependency and never resolve a chat runtime, this fixture is never
|
||||
* triggered and the ingress stays a GREEN control.
|
||||
*/
|
||||
function failIfUsedChatRuntimeRouter() {
|
||||
return {
|
||||
onModuleInit: () => {
|
||||
throw new Error('chat runtime router must not initialise on the command approval path');
|
||||
},
|
||||
get active(): never {
|
||||
throw new Error('chat runtime must not be resolved on the command approval path');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildGateway(commandExecutor: {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
createApproval: ReturnType<typeof vi.fn>;
|
||||
}): ChatGateway {
|
||||
return new ChatGateway(
|
||||
{} as never,
|
||||
failIfUsedChatRuntimeRouter() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -72,3 +91,114 @@ describe('ChatGateway command approval ingress', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
|
||||
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
|
||||
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
|
||||
* fall-through to embedded execution. Only runtime-independent audited system commands
|
||||
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
|
||||
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
|
||||
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
|
||||
*/
|
||||
function buildPiRpcGateway(commandExecutor: {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
createApproval: ReturnType<typeof vi.fn>;
|
||||
}): ChatGateway {
|
||||
const piRpcRouter = {
|
||||
runtimeMode: 'pi-rpc' as const,
|
||||
onModuleInit: () => {
|
||||
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
|
||||
},
|
||||
get active(): never {
|
||||
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
|
||||
},
|
||||
};
|
||||
return new ChatGateway(
|
||||
piRpcRouter as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
commandExecutor as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
|
||||
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
|
||||
|
||||
it.each(['model', 'agent', 'gc'])(
|
||||
'fails /%s closed before the executor under pi-rpc (execute never called)',
|
||||
async (command): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
|
||||
createApproval: vi.fn(),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandExecute(client as never, {
|
||||
command,
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
|
||||
expect(client.emit).toHaveBeenCalledWith('command:result', {
|
||||
command,
|
||||
conversationId: 'conversation-1',
|
||||
success: false,
|
||||
message: UNSUPPORTED,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
|
||||
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
|
||||
const commandExecutor = {
|
||||
execute: vi.fn().mockResolvedValue(reloadResult),
|
||||
createApproval: vi.fn(),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandExecute(client as never, {
|
||||
command: 'reload',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
expect(commandExecutor.execute).toHaveBeenCalledWith(
|
||||
{ command: 'reload', conversationId: 'conversation-1' },
|
||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
|
||||
});
|
||||
|
||||
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi.fn(),
|
||||
createApproval: vi.fn().mockResolvedValue({
|
||||
approvalId: 'approval-1',
|
||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||
}),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandApproval(client as never, {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
||||
{ command: 'gc', conversationId: 'conversation-1' },
|
||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith(
|
||||
'command:approval',
|
||||
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,59 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { HarnessModule } from '../harness/harness.module.js';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import {
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
HARNESS_REGISTRY,
|
||||
type HarnessConversationServiceBinding,
|
||||
} from '../harness/harness.tokens.js';
|
||||
import type { HarnessConversationService } from '@mosaicstack/types';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { ChatController } from './chat.controller.js';
|
||||
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||
import { EmbeddedChatRuntime } from './embedded-chat.runtime.js';
|
||||
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||
|
||||
/**
|
||||
* Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution
|
||||
* authority: the controller and gateway inject only the router, never `AgentService`,
|
||||
* `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one
|
||||
* runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime}
|
||||
* in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding.
|
||||
*
|
||||
* The router and the harness runtime are constructed through factories because their
|
||||
* dependencies are interface/union types with no runtime injection token (the registry and
|
||||
* conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded
|
||||
* runtime injects the class-typed `AgentService` and is provided directly.
|
||||
*/
|
||||
@Module({
|
||||
imports: [forwardRef(() => CommandsModule)],
|
||||
imports: [forwardRef(() => CommandsModule), HarnessModule],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
exports: [ChatGateway],
|
||||
providers: [
|
||||
ChatGateway,
|
||||
EmbeddedChatRuntime,
|
||||
{
|
||||
provide: HarnessChatRuntime,
|
||||
useFactory: (conversationService: HarnessConversationServiceBinding) =>
|
||||
new HarnessChatRuntime(conversationService as HarnessConversationService),
|
||||
inject: [HARNESS_CONVERSATION_SERVICE],
|
||||
},
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: (
|
||||
registry: HarnessRegistry,
|
||||
conversationService: HarnessConversationServiceBinding,
|
||||
embedded: EmbeddedChatRuntime,
|
||||
harness: HarnessChatRuntime,
|
||||
) => new ChatRuntimeRouter(registry, conversationService, embedded, harness),
|
||||
inject: [
|
||||
HARNESS_REGISTRY,
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
EmbeddedChatRuntime,
|
||||
HarnessChatRuntime,
|
||||
],
|
||||
},
|
||||
],
|
||||
exports: [ChatGateway, ChatRuntimeRouter],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import type {
|
||||
ChatRuntime,
|
||||
LegacyBrowserMessagePayload,
|
||||
LegacyEmbeddedChatPort,
|
||||
LegacyRuntimeEvent,
|
||||
LegacyRuntimeResult,
|
||||
LegacySessionPresentation,
|
||||
LegacySocketTurnLease,
|
||||
LegacyUsage,
|
||||
OwnedConversationContext,
|
||||
VerifiedDiscordIngressContext,
|
||||
VerifiedDiscordTurnLease,
|
||||
LegacyRuntimeStream,
|
||||
} from './chat-runtime.js';
|
||||
|
||||
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
|
||||
const REST_TURN_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
|
||||
*
|
||||
* It owns the embedded in-process execution path — the `AgentService` stack that the
|
||||
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
|
||||
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
|
||||
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
|
||||
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
|
||||
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
|
||||
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
|
||||
* collapses to `conversation_unavailable` and never throws out of the port.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
|
||||
readonly kind = 'embedded' as const;
|
||||
private readonly logger = new Logger(EmbeddedChatRuntime.name);
|
||||
|
||||
constructor(readonly agentService: AgentService) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy REST completion (op A)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(conversationId, scope, {});
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let responseText = '';
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let detach: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
|
||||
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
|
||||
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
|
||||
// would return while leaving the listener attached (free to consume a later turn's events) and
|
||||
// the 120s timer live (its rejection later going unobserved).
|
||||
const dispose = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
detach?.();
|
||||
};
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
dispose();
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, REST_TURN_TIMEOUT_MS);
|
||||
|
||||
detach = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
dispose();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
});
|
||||
|
||||
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
|
||||
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
|
||||
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
|
||||
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
|
||||
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
|
||||
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
|
||||
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
|
||||
const prompting = this.agentService.prompt(conversationId, input.content, scope);
|
||||
try {
|
||||
await Promise.all([prompting, done]);
|
||||
} catch (err) {
|
||||
dispose();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('timed out')) {
|
||||
return { ok: false, code: 'timeout', retryable: true };
|
||||
}
|
||||
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
|
||||
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
|
||||
return { ok: true, value: { text: responseText, presentation } };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy Socket streaming (op B)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(conversationId, scope, {
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
...(input.modelId ? { modelId: input.modelId } : {}),
|
||||
...(input.agentId ? { agentConfigId: input.agentId } : {}),
|
||||
});
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let detach: () => void;
|
||||
try {
|
||||
detach = this.subscribe(conversationId, scope, stream);
|
||||
} catch (err) {
|
||||
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||
this.logger.error(
|
||||
`Embedded socket subscription failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: this.buildLease(
|
||||
conversationId,
|
||||
scope,
|
||||
input.content,
|
||||
input.attachments,
|
||||
detach,
|
||||
resolved.presentation,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Thinking level (op C) — synchronous, total
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
|
||||
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'thinking_level_invalid',
|
||||
retryable: false,
|
||||
availableThinkingLevels,
|
||||
};
|
||||
}
|
||||
|
||||
session.piSession.setThinkingLevel(level as never);
|
||||
return { ok: true, value: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Abort (op D)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
try {
|
||||
await session.piSession.abort();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Legacy abort failed for conversation=${context.conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Model override (synchronous, total)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
|
||||
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
|
||||
return { ok: true, value: this.presentationForSession(refreshed) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Presentation read (synchronous, total)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
return { ok: true, value: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verified Discord ingress (embedded-only in both modes)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(
|
||||
conversationId,
|
||||
scope,
|
||||
{ agentConfigId: context.configuredAgent.agentConfigId },
|
||||
{
|
||||
agentConfigId: context.configuredAgent.agentConfigId,
|
||||
instanceId: context.configuredAgent.instanceId,
|
||||
},
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let detach: () => void;
|
||||
try {
|
||||
detach = this.subscribe(conversationId, scope, stream);
|
||||
} catch (err) {
|
||||
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||
this.logger.error(
|
||||
`Embedded Discord subscription failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: this.buildLease(
|
||||
conversationId,
|
||||
scope,
|
||||
context.content,
|
||||
context.attachments,
|
||||
detach,
|
||||
resolved.presentation,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the owned session, creating it on first use. Ownership/scope rejections
|
||||
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
|
||||
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
|
||||
* session presentation so callers avoid a redundant `getSession`.
|
||||
*/
|
||||
private async resolveOrCreate(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
|
||||
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
|
||||
): Promise<
|
||||
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
|
||||
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
|
||||
> {
|
||||
// A verified-Discord turn may only run under a session whose configured identity matches the
|
||||
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
|
||||
// pre-existing session AND a freshly created one: a session carrying a different configured
|
||||
// agent — however it arose — is rejected rather than executed under the verified label, so we
|
||||
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
|
||||
// no expectedAgent and skips the check.
|
||||
const identityMatches = (candidate: AgentSession): boolean =>
|
||||
expectedAgent === undefined ||
|
||||
(candidate.agentConfigId === expectedAgent.agentConfigId &&
|
||||
candidate.agentName === expectedAgent.instanceId);
|
||||
|
||||
let session = this.agentService.getSession(conversationId, scope);
|
||||
if (session && !identityMatches(session)) {
|
||||
// Reused same-scope session minted under a different configured identity — reject with zero
|
||||
// effects rather than dispatch a verified turn onto a foreign agent's session.
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
if (!session) {
|
||||
try {
|
||||
session = await this.agentService.createSession(conversationId, {
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
...extraOptions,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
this.logger.error(
|
||||
`Embedded session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
// The just-created session must ALSO carry the reconciled identity before any effect. A
|
||||
// createSession that returns a session under a different configured agent (misconfiguration
|
||||
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
|
||||
if (!identityMatches(session)) {
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
return { ok: true, presentation: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
/** Installs a normalizing event listener that forwards to the server-owned stream. */
|
||||
private subscribe(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
stream: LegacyRuntimeStream,
|
||||
): () => void {
|
||||
const unsubscribe = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
const normalized = this.normalizeEvent(conversationId, scope, event);
|
||||
if (normalized) stream.onEvent(normalized);
|
||||
},
|
||||
scope,
|
||||
);
|
||||
try {
|
||||
this.agentService.addChannel(conversationId, stream.channelId, scope);
|
||||
} catch (err) {
|
||||
// Partial setup: the listener was acquired but the channel attach failed. Roll back
|
||||
// exactly what was acquired (the listener) before the failure escapes, so no leaked
|
||||
// subscription survives; the caller converts the rethrow into a total safe failure.
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
try {
|
||||
this.agentService.removeChannel(conversationId, stream.channelId, scope);
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
|
||||
private buildLease(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
content: string,
|
||||
attachments: VerifiedDiscordIngressContext['attachments'],
|
||||
detach: () => void,
|
||||
presentation: LegacySessionPresentation,
|
||||
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
|
||||
let dispatched = false;
|
||||
let disposed = false;
|
||||
return {
|
||||
presentation,
|
||||
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
|
||||
if (dispatched) {
|
||||
return { ok: false, code: 'turn_already_dispatched', retryable: false };
|
||||
}
|
||||
dispatched = true;
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, content, scope, attachments);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Legacy dispatch failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
dispose: async (): Promise<void> => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
|
||||
private normalizeEvent(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
event: AgentSessionEvent,
|
||||
): LegacyRuntimeEvent | undefined {
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
return { type: 'started' };
|
||||
case 'agent_end':
|
||||
return { type: 'settled', ...this.usageFor(conversationId, scope) };
|
||||
case 'message_update': {
|
||||
const assistant = event.assistantMessageEvent;
|
||||
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
|
||||
if (assistant.type === 'thinking_delta') {
|
||||
return { type: 'thinking_delta', text: assistant.delta };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case 'tool_execution_start':
|
||||
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
|
||||
case 'tool_execution_end':
|
||||
return {
|
||||
type: 'tool_finished',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
isError: event.isError,
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers terminal usage from the Pi session and records it into session metrics.
|
||||
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
|
||||
*/
|
||||
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
const piSession = session?.piSession;
|
||||
const stats = piSession?.getSessionStats();
|
||||
if (!session || !stats) return {};
|
||||
const contextUsage = piSession?.getContextUsage();
|
||||
|
||||
const tokens = {
|
||||
input: stats.tokens?.input ?? 0,
|
||||
output: stats.tokens?.output ?? 0,
|
||||
cacheRead: stats.tokens?.cacheRead ?? 0,
|
||||
cacheWrite: stats.tokens?.cacheWrite ?? 0,
|
||||
total: stats.tokens?.total ?? 0,
|
||||
};
|
||||
|
||||
this.agentService.recordTokenUsage(conversationId, { ...tokens });
|
||||
|
||||
return {
|
||||
usage: {
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||
tokens,
|
||||
cost: stats.cost ?? 0,
|
||||
context: {
|
||||
percent: contextUsage?.percent ?? null,
|
||||
window: contextUsage?.contextWindow ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Presentation from a live session id, or undefined when no owned session exists. */
|
||||
private presentationFor(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): LegacySessionPresentation | undefined {
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
return session ? this.presentationForSession(session) : undefined;
|
||||
}
|
||||
|
||||
/** User-facing projection carrying no session handle, credential, or raw stats. */
|
||||
private presentationForSession(session: AgentSession): LegacySessionPresentation {
|
||||
return {
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: session.piSession.thinkingLevel,
|
||||
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
|
||||
const CONVERSATION_UNAVAILABLE = {
|
||||
ok: false as const,
|
||||
code: 'conversation_unavailable' as const,
|
||||
retryable: false as const,
|
||||
};
|
||||
|
||||
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
|
||||
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
|
||||
return { userId: scope.userId, tenantId: scope.tenantId };
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
AttachConversation,
|
||||
ConversationSnapshot,
|
||||
DetachConversation,
|
||||
HarnessActorContext,
|
||||
HarnessConversationService,
|
||||
HarnessEventEnvelope,
|
||||
HarnessSelection,
|
||||
SendHarnessTurn,
|
||||
TurnReceipt,
|
||||
} from '@mosaicstack/types';
|
||||
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||
|
||||
/**
|
||||
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
|
||||
* exclusively through the {@link HarnessConversationService} RPC boundary and
|
||||
* forwards the caller's exact selection tuple and idempotency key without
|
||||
* substitution. Red-first: the runtime is an unimplemented stub, so every
|
||||
* delegation assertion fails until Step Three.
|
||||
*/
|
||||
|
||||
const context: HarnessActorContext = {
|
||||
actorId: 'actor-1',
|
||||
tenantId: 'tenant-1',
|
||||
seatId: 'seat-1',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude-opus-4-8',
|
||||
};
|
||||
|
||||
const conversationId = '11111111-1111-4111-8111-111111111111';
|
||||
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
|
||||
context,
|
||||
conversationId,
|
||||
selection,
|
||||
turnId: 'turn-abc',
|
||||
correlationId: 'corr-1',
|
||||
content: 'hello',
|
||||
idempotencyKey,
|
||||
};
|
||||
|
||||
const attachInput: AttachConversation & { afterSequence?: number } = {
|
||||
context,
|
||||
conversationId,
|
||||
clientId: 'client-1',
|
||||
selection,
|
||||
afterSequence: 0,
|
||||
};
|
||||
|
||||
const detachInput: DetachConversation = {
|
||||
context,
|
||||
conversationId,
|
||||
clientId: 'client-1',
|
||||
};
|
||||
|
||||
interface RecordedCalls {
|
||||
attach: (AttachConversation & { afterSequence?: number })[];
|
||||
detach: DetachConversation[];
|
||||
send: (SendHarnessTurn & { idempotencyKey: string })[];
|
||||
subscribeFrom: { conversationId: string; afterSequence: number }[];
|
||||
}
|
||||
|
||||
const snapshot: ConversationSnapshot = {
|
||||
session: {
|
||||
conversationId,
|
||||
nativeSessionId: 'native-1',
|
||||
seatId: 'seat-1',
|
||||
selection,
|
||||
state: 'idle',
|
||||
attachedClientIds: ['client-1'],
|
||||
},
|
||||
lastSequence: 0,
|
||||
replay: [],
|
||||
};
|
||||
|
||||
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
|
||||
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
|
||||
const service: HarnessConversationService = {
|
||||
attach: (input) => {
|
||||
calls.attach.push(input);
|
||||
return Promise.resolve(snapshot);
|
||||
},
|
||||
detach: (input) => {
|
||||
calls.detach.push(input);
|
||||
return Promise.resolve();
|
||||
},
|
||||
send: (input) => {
|
||||
calls.send.push(input);
|
||||
// The service echoes only the requested tuple; there is no representable substitute.
|
||||
const receipt: TurnReceipt = {
|
||||
conversationId: input.conversationId,
|
||||
turnId: 'turn-server',
|
||||
correlationId: input.correlationId,
|
||||
state: 'accepted',
|
||||
selection: input.selection,
|
||||
};
|
||||
return Promise.resolve(receipt);
|
||||
},
|
||||
subscribeFrom: (id, afterSequence) => {
|
||||
calls.subscribeFrom.push({ conversationId: id, afterSequence });
|
||||
|
||||
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
|
||||
return;
|
||||
})();
|
||||
},
|
||||
};
|
||||
return { runtime: new HarnessChatRuntime(service), calls };
|
||||
}
|
||||
|
||||
describe('HarnessChatRuntime', () => {
|
||||
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
|
||||
const { runtime } = build();
|
||||
expect(runtime.kind).toBe('harness');
|
||||
});
|
||||
|
||||
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const receipt = await runtime.send(sendInput);
|
||||
|
||||
expect(calls.send).toHaveLength(1);
|
||||
const firstSend = calls.send[0]!;
|
||||
expect(firstSend).toEqual(sendInput);
|
||||
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
|
||||
expect(firstSend.selection).toEqual(selection);
|
||||
// The runtime must not substitute an effective tuple onto the receipt.
|
||||
expect(receipt.selection).toEqual(selection);
|
||||
});
|
||||
|
||||
it('delegates attach to the conversation service and returns its snapshot', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const result = await runtime.attach(attachInput);
|
||||
|
||||
expect(calls.attach).toHaveLength(1);
|
||||
expect(calls.attach[0]).toEqual(attachInput);
|
||||
expect(result).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('delegates detach to the conversation service', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
await runtime.detach(detachInput);
|
||||
|
||||
expect(calls.detach).toHaveLength(1);
|
||||
expect(calls.detach[0]).toEqual(detachInput);
|
||||
});
|
||||
|
||||
it('delegates subscribeFrom to the conversation service journal replay', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const iterable = runtime.subscribeFrom(conversationId, 7);
|
||||
// Drain to prove it is the service-backed async iterable, not a fabricated one.
|
||||
const drained: unknown[] = [];
|
||||
for await (const event of iterable) {
|
||||
drained.push(event);
|
||||
}
|
||||
expect(drained).toHaveLength(0);
|
||||
|
||||
expect(calls.subscribeFrom).toHaveLength(1);
|
||||
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AttachConversation,
|
||||
ConversationSnapshot,
|
||||
DetachConversation,
|
||||
HarnessConversationService,
|
||||
HarnessEventEnvelope,
|
||||
SendHarnessTurn,
|
||||
TurnReceipt,
|
||||
} from '@mosaicstack/types';
|
||||
import type { ChatRuntime } from './chat-runtime.js';
|
||||
|
||||
/**
|
||||
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
|
||||
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
|
||||
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
|
||||
* stack, and it forwards the caller's exact selection tuple and idempotency key
|
||||
* without substitution.
|
||||
*
|
||||
* It owns no state and adds no policy: every method forwards the caller's exact
|
||||
* argument to the injected {@link HarnessConversationService} and returns its
|
||||
* result unchanged, so the requested selection tuple and idempotency key can
|
||||
* never be substituted on the way through.
|
||||
*/
|
||||
export class HarnessChatRuntime implements ChatRuntime {
|
||||
readonly kind = 'harness' as const;
|
||||
|
||||
constructor(private readonly conversations: HarnessConversationService) {}
|
||||
|
||||
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
|
||||
return this.conversations.attach(input);
|
||||
}
|
||||
|
||||
detach(input: DetachConversation): Promise<void> {
|
||||
return this.conversations.detach(input);
|
||||
}
|
||||
|
||||
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
|
||||
return this.conversations.send(input);
|
||||
}
|
||||
|
||||
subscribeFrom(
|
||||
conversationId: string,
|
||||
afterSequence: number,
|
||||
): AsyncIterable<HarnessEventEnvelope> {
|
||||
return this.conversations.subscribeFrom(conversationId, afterSequence);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user