Gate JSDoc completeness on every package export
New doc-sync gate verify-export-jsdoc walks every module-level exported name under packages/*/*/src and requires description prose everywhere, plus @param per parameter and @returns on non-void annotated returns for function-like exports, public class methods, properties, and accessors. The parsing + check helpers move out of gen-cordis-catalog.ts into a shared scripts/jsdoc.ts so 'documented' means one thing on both gated surfaces. Deliberate exemptions (documented in the RFC): heritage-declared class members (the seam declaration is the doc's one home — the one checker query in an otherwise pure-AST walk), cordis plugin-protocol slots (name/inject/reusable/Config/apply, top-level and static), constructors, overload implementations, declare-module augmentation bodies, and re-export statements (checked at the defining module). The 203 under-documented exports the gate found at adoption are filled in this change, so the gate lands green; generated catalogs/graphs are regenerated for the shifted line pointers. RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
This commit is contained in:
@@ -22,6 +22,10 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/**
|
||||
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
|
||||
* the driver loop can drain it; {@link cancel} clears it wholesale.
|
||||
*/
|
||||
readonly inbox = new Inbox()
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
@@ -256,6 +260,8 @@ export class ReactLoopAgent implements Agent {
|
||||
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
|
||||
* aborts the current request if any. The returned `agent.done` promise
|
||||
* resolves once the loop exits.
|
||||
* @returns the disposer — idempotent and infallible (it runs inside the
|
||||
* fiber's LIFO disposal chain, where a throw would skip later disposers).
|
||||
*/
|
||||
start(): () => void {
|
||||
this.done = runLoop(this.ctx, this, {
|
||||
|
||||
@@ -24,30 +24,47 @@ export class Inbox {
|
||||
private steeringMessages: InboxMessage[] = []
|
||||
private wakeup: (() => void) | undefined
|
||||
|
||||
/** Resolves when a queued message arrives (used by the idle loop). */
|
||||
/** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
|
||||
get hasQueued(): boolean {
|
||||
return this.queuedMessages.length > 0
|
||||
}
|
||||
|
||||
/** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */
|
||||
get hasSteering(): boolean {
|
||||
return this.steeringMessages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
|
||||
* @param message - the message to queue for the next turn start.
|
||||
*/
|
||||
enqueue(message: InboxMessage): void {
|
||||
this.queuedMessages.push(message)
|
||||
this.wakeup?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
|
||||
* drained between steps of a running turn, never by the idle wait —
|
||||
* `Agent.steer()` on an idle agent falls back to `send()` instead.
|
||||
* @param message - the message to inject between steps of the running turn.
|
||||
*/
|
||||
steer(message: InboxMessage): void {
|
||||
this.steeringMessages.push(message)
|
||||
}
|
||||
|
||||
/** Drain all queued messages (turn start). */
|
||||
/**
|
||||
* Drain all queued messages (turn start).
|
||||
* @returns the drained messages in arrival order; the queued FIFO is left empty.
|
||||
*/
|
||||
drainQueued(): InboxMessage[] {
|
||||
return this.queuedMessages.splice(0)
|
||||
}
|
||||
|
||||
/** Drain all steering messages (between steps). */
|
||||
/**
|
||||
* Drain all steering messages (between steps).
|
||||
* @returns the drained messages in arrival order; the steering FIFO is left empty.
|
||||
*/
|
||||
drainSteering(): InboxMessage[] {
|
||||
return this.steeringMessages.splice(0)
|
||||
}
|
||||
@@ -62,7 +79,12 @@ export class Inbox {
|
||||
this.steeringMessages.length = 0
|
||||
}
|
||||
|
||||
/** Wait until a queued message arrives or `cancel` resolves. */
|
||||
/**
|
||||
* Wait until a queued message arrives or `cancel` resolves.
|
||||
* @param cancel - a promise whose resolution abandons the wait without a
|
||||
* message (the driver loop passes the agent's disposed promise so a parked
|
||||
* loop can exit).
|
||||
*/
|
||||
waitForQueued(cancel: Promise<void>): Promise<void> {
|
||||
if (this.hasQueued) return Promise.resolve()
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
|
||||
@@ -29,6 +29,10 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
|
||||
* declaratively at startup, so a cordis.yml deployment needs no code.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Agents created from configuration at startup. */
|
||||
agents: (AgentOptions & {
|
||||
|
||||
@@ -185,6 +185,9 @@ export interface LoopHandle {
|
||||
* re-enqueue leftover steering as queued ⟵ steering is never stranded
|
||||
* idle (emit agent/status) unless more queued
|
||||
* ```
|
||||
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
|
||||
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
|
||||
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
|
||||
// Per-instance transmission bookkeeping: whether THIS loop instance has
|
||||
@@ -875,7 +878,11 @@ function withoutToolCalls(message: Message): Message {
|
||||
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
|
||||
}
|
||||
|
||||
/** The last turn number in a (possibly seeded) session log, or 0. */
|
||||
/**
|
||||
* The last turn number in a (possibly seeded) session log, or 0.
|
||||
* @param session - the session whose log is scanned for the latest `turn/start`.
|
||||
* @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
|
||||
*/
|
||||
export function lastTurnNumber(session: Session): number {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
return lastStart?.data.turn ?? 0
|
||||
@@ -889,6 +896,8 @@ export function lastTurnNumber(session: Session): number {
|
||||
* returns to idle), so status is not a reliable open-turn signal. Used by
|
||||
* `inject()` to choose between appending into an open turn vs. wrapping the
|
||||
* injection in its own one-shot turn (the turn-enclosure RFC).
|
||||
* @param session - the session whose log is inspected.
|
||||
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
|
||||
*/
|
||||
export function isTurnOpen(session: Session): boolean {
|
||||
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
|
||||
|
||||
@@ -19,7 +19,10 @@ export interface TransmissionLog {
|
||||
loggedHeader: boolean
|
||||
}
|
||||
|
||||
/** Fresh bookkeeping for a newly-started loop instance. */
|
||||
/**
|
||||
* Fresh bookkeeping for a newly-started loop instance.
|
||||
* @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot.
|
||||
*/
|
||||
export function createTransmissionLog(): TransmissionLog {
|
||||
return { loggedHeader: false }
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
/** Identifies one live agent in the registry. */
|
||||
export type AgentId = Branded<'AgentId'>
|
||||
|
||||
/** Brand a string as an {@link AgentId}. */
|
||||
/**
|
||||
* Brand a string as an {@link AgentId}.
|
||||
* @param id - the raw agent id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function AgentId(id: string): AgentId {
|
||||
return id as AgentId
|
||||
}
|
||||
@@ -80,10 +84,22 @@ export interface AgentOptions {
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An
|
||||
* absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content
|
||||
* must label itself here or its message is recorded as a user prompt (see
|
||||
* {@link HookContext} on why that label is load-bearing).
|
||||
*/
|
||||
export interface SendOptions {
|
||||
source?: MessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
|
||||
* `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
|
||||
* throw).
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
/**
|
||||
|
||||
296
packages/core/agent/tests/verify-export-jsdoc.spec.ts
Normal file
296
packages/core/agent/tests/verify-export-jsdoc.spec.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Negative-path tests for the export-surface JSDoc gate
|
||||
* (`scripts/verify-export-jsdoc.ts`).
|
||||
*
|
||||
* The gate's positive half runs against the real tree in CI (`pnpm run
|
||||
* verify-export-jsdoc`, part of doc-sync). What that run cannot prove is that
|
||||
* the walk REJECTS an undocumented surface the way it promises to — and that
|
||||
* every deliberate exemption (heritage members, plugin-protocol slots,
|
||||
* constructors, overload implementations, augmentation bodies, re-exports)
|
||||
* actually holds. These tests drive `collectExportJsdocViolations()` against
|
||||
* synthetic fixture packages, mirroring the gen-cordis-catalog negative
|
||||
* tests.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectExportJsdocViolations } from '../../../../scripts/verify-export-jsdoc.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Write fixture files under `packages/group/fix/src/` and return the scan root. */
|
||||
function fixture(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'export-jsdoc-'))
|
||||
roots.push(root)
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = join(root, 'packages', 'group', 'fix', 'src', rel)
|
||||
mkdirSync(dirname(abs), { recursive: true })
|
||||
writeFileSync(abs, content)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Single-file fixture shorthand: the content becomes `src/index.ts`. */
|
||||
const make = (content: string): string => fixture({ 'index.ts': content })
|
||||
|
||||
describe('verify-export-jsdoc functions and consts', () => {
|
||||
it('accepts a fully documented surface', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/**
|
||||
* Add one to a count.
|
||||
* @param n - the count to bump.
|
||||
* @returns the count plus one.
|
||||
*/
|
||||
export function bump(n: number): number { return n + 1 }
|
||||
|
||||
/**
|
||||
* Fire-and-forget (void needs no @returns).
|
||||
* @param flag - whether to arm.
|
||||
*/
|
||||
export function poke(flag: boolean): void { void flag }
|
||||
|
||||
/** The default retry budget. */
|
||||
export const RETRIES = 3
|
||||
|
||||
/**
|
||||
* Halve a count.
|
||||
* @param n - the count to halve.
|
||||
* @returns the count halved.
|
||||
*/
|
||||
export const halve = (n: number): number => n / 2
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('flags an exported function with no JSDoc at all', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'export function bare(): void {}\n',
|
||||
))).toEqual([expect.stringMatching(/exported function 'bare' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('flags a missing @param and a missing @returns', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'/** Docs without tags. */\nexport function f(x: number): number { return x }\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported function 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported function 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('flags an unannotated (inferred) return type', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(x: number) { return x }\n',
|
||||
))).toEqual([expect.stringMatching(/no return type annotation/)])
|
||||
})
|
||||
|
||||
it('flags tags-only JSDoc with no description prose', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * @param x - value.\n */\nexport function f(x: number): void {}\n',
|
||||
))).toEqual([expect.stringMatching(/no description prose above its block tags/)])
|
||||
})
|
||||
|
||||
it('flags a stale @param and a binding-pattern parameter', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
|
||||
expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('exempts a `this` receiver annotation from @param', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(this: object, x: number): void {}\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('waives @returns for a declarator-annotated const but not an unannotated one', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
type Fn = (x: number) => number
|
||||
/**
|
||||
* Uses the named signature.
|
||||
* @param x - value.
|
||||
*/
|
||||
export const good: Fn = x => x
|
||||
/**
|
||||
* No signature anywhere.
|
||||
* @param x - value.
|
||||
*/
|
||||
export const bad = (x: number) => x
|
||||
`))).toEqual([expect.stringMatching(/exported const 'bad' .* has no return type annotation/)])
|
||||
})
|
||||
|
||||
it('requires description prose on a non-function const', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'export const LIMIT = 10\n',
|
||||
))).toEqual([expect.stringMatching(/exported const 'LIMIT' .* has no JSDoc\./)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc type-level exports', () => {
|
||||
it('requires description prose on interfaces, type aliases, and enums', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'export interface I { a: number }\nexport type T = number\nexport enum E { A }\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported interface 'I' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported type 'T' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported enum 'E' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
"declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n",
|
||||
))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc export forms', () => {
|
||||
it('resolves an `export { … }` list to the local declaration', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'function f(): void {}\nexport { f }\n',
|
||||
))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('reports a re-exported module once, at its defining file', () => {
|
||||
const violations = collectExportJsdocViolations(fixture({
|
||||
'index.ts': "export * from './other.ts'\n",
|
||||
'other.ts': 'export function f(): void {}\n',
|
||||
}))
|
||||
expect(violations).toEqual([expect.stringMatching(/other\.ts:1\) has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('exempts overload implementations when the signatures are documented', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/**
|
||||
* From a number.
|
||||
* @param x - the number.
|
||||
* @returns its text.
|
||||
*/
|
||||
export function f(x: number): string
|
||||
/**
|
||||
* From a flag.
|
||||
* @param x - the flag.
|
||||
* @returns its text.
|
||||
*/
|
||||
export function f(x: boolean): string
|
||||
export function f(x: number | boolean): string { return String(x) }
|
||||
`))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc classes', () => {
|
||||
it('flags an undocumented class, method, property, and accessor', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
export class C {
|
||||
state = 1
|
||||
get view(): number { return this.state }
|
||||
run(x: number): number { return x }
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported class 'C' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class property 'C.state' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class accessor 'C.view' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class method 'C.run' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('exempts members declared by an extends/implements heritage type', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Do it.
|
||||
* @param x - input.
|
||||
* @returns output.
|
||||
*/
|
||||
abstract run(x: number): number
|
||||
}
|
||||
/** Iface. */
|
||||
export interface Sized {
|
||||
/** Byte size. */
|
||||
size: number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base implements Sized {
|
||||
size = 0
|
||||
run(x: number): number { return x }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('skips private/protected/#private members and constructors', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Documented. */
|
||||
export class C {
|
||||
#secret = 1
|
||||
private hidden(): void {}
|
||||
protected hook(): void {}
|
||||
constructor(x: number) { void x }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('exempts plugin-protocol statics but checks other statics', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
/** Plugin. */
|
||||
export class C {
|
||||
static Config = { a: 1 }
|
||||
static inject = ['bash']
|
||||
static reusable = true
|
||||
static other = 1
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([expect.stringMatching(/exported class property 'C.other' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it("covers a set accessor by the getter's doc", () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Documented. */
|
||||
export class C {
|
||||
/** The current width. */
|
||||
get width(): number { return 1 }
|
||||
set width(_v: number) {}
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc plugin protocol and namespaces', () => {
|
||||
it('exempts top-level plugin-protocol exports', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
export const name = 'fix'
|
||||
export const inject = ['bash']
|
||||
export const reusable = true
|
||||
export const Config = { parse: true }
|
||||
export function apply(): void {}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('recurses into namespaces with qualified names and honors the merge idiom', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
/** The plugin class. */
|
||||
export class Fix {}
|
||||
export namespace Fix {
|
||||
export interface Config { a: number }
|
||||
}
|
||||
export namespace Loose {
|
||||
export const x = 1
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported interface 'Fix.Config' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported namespace 'Loose' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported const 'Loose.x' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -153,10 +153,15 @@ export class Session {
|
||||
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The append-only event log, exposed live by reference (readonly-typed, not
|
||||
* a snapshot): later appends are visible through the same array.
|
||||
*/
|
||||
get events(): readonly SessionEvent[] {
|
||||
return this.log
|
||||
}
|
||||
|
||||
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
|
||||
get seq(): number {
|
||||
return this.log.length
|
||||
}
|
||||
@@ -175,6 +180,9 @@ export class Session {
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
* `data` that entered the log, so reading `event.data` back sees the logged
|
||||
* value, never the caller's still-mutable input.
|
||||
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
|
||||
* symbol, undefined, non-finite number, circular ref, or an exotic object
|
||||
* like Map/Set/Date). The event log is the durable source of truth, so this
|
||||
|
||||
@@ -40,6 +40,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key:
|
||||
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
|
||||
* Getters are invoked during the check (again as `JSON.stringify` would), so the
|
||||
* contract is for plain data records, not objects with side-effecting accessors.
|
||||
* @param value - the candidate event data to test.
|
||||
* @param seen - objects on the current descent path, for circular-reference
|
||||
* detection; the recursion threads it — callers omit it.
|
||||
* @returns true when `value` survives a JSON round-trip losslessly.
|
||||
*/
|
||||
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
||||
if (value === null) return true
|
||||
|
||||
@@ -54,6 +54,8 @@ import type { SessionEvent } from './types.ts'
|
||||
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
|
||||
* before any later `turn/start`, so an interior open turn is impossible in a
|
||||
* valid committed log. Likewise at most one step is open within that turn.
|
||||
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
||||
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
||||
*/
|
||||
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
|
||||
let openTurn: number | null = null
|
||||
|
||||
@@ -29,6 +29,8 @@ const SURFACE_EVENT_TYPES = new Set<string>([
|
||||
* surface-eligible event that is MISSING its mandatory marker (e.g. validating
|
||||
* a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed
|
||||
* {@link SurfaceEvent} with `surfaceOp` present.
|
||||
* @param type - the event type string to test.
|
||||
* @returns true when the type is one of the five message-producing types.
|
||||
*/
|
||||
export function isSurfaceEligibleType(type: string): boolean {
|
||||
return SURFACE_EVENT_TYPES.has(type)
|
||||
@@ -38,6 +40,8 @@ export function isSurfaceEligibleType(type: string): boolean {
|
||||
* Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the
|
||||
* event's `type` is surface-eligible AND that `surfaceOp` is present.
|
||||
* The narrowed type has mandatory {@link SurfaceOp}.
|
||||
* @param event - the event to narrow.
|
||||
* @returns true when the event is surface-eligible and carries its `surfaceOp` marker.
|
||||
*/
|
||||
export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
|
||||
if (!SURFACE_EVENT_TYPES.has(event.type)) return false
|
||||
|
||||
@@ -74,6 +74,12 @@ function nodeDelta(event: SessionEvent): number {
|
||||
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
|
||||
* for the cut after `end`.
|
||||
*
|
||||
* @param nodes - the surface linked list in head→tail order.
|
||||
* @param events - the session log each node's `seq` indexes into.
|
||||
* @param beforeSeq - names the cut (the node it sits immediately before);
|
||||
* `null` — or any seq not on the surface — means the after-tail cut.
|
||||
* @returns true when every `tool-call` before the cut is answered before it
|
||||
* (the unanswered-call depth at the cut is zero).
|
||||
* @throws if the surface prefix drives the unanswered-call depth negative — a
|
||||
* `tool/result` with no preceding open `tool-call` on the surface. That is a
|
||||
* corrupt surface (a structural invariant violation), surfaced loudly here
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, T
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
/** Brand a string as a {@link SessionId}. */
|
||||
/**
|
||||
* Brand a string as a {@link SessionId}.
|
||||
* @param id - the raw session id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function SessionId(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
@@ -102,6 +106,7 @@ export interface TurnTriggerMap {
|
||||
injection: { kind: 'injection'; source: MessageSource }
|
||||
}
|
||||
|
||||
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
|
||||
/**
|
||||
@@ -156,6 +161,7 @@ export interface TurnEndReasonMap {
|
||||
interrupted: { kind: 'interrupted' }
|
||||
}
|
||||
|
||||
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
|
||||
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
|
||||
/**
|
||||
@@ -361,6 +367,7 @@ export interface SessionEventMap {
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
export type SessionEventType = keyof SessionEventMap
|
||||
|
||||
/**
|
||||
|
||||
@@ -110,6 +110,7 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
||||
/** A complete `{{...}}` reference group at the scan position (validated after). */
|
||||
const GROUP_AT = /^\{\{([^{}]*)\}\}/
|
||||
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's persona — the ONE deployment-authored fragment of the
|
||||
@@ -138,6 +139,10 @@ export interface Config {
|
||||
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
|
||||
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
|
||||
* through verbatim. Substituted values are never re-scanned.
|
||||
* @param assembly - the assembly to render (typically the awaited result of
|
||||
* {@link SystemPrompt.assemble}); only `sections` and `variables` are read.
|
||||
* @returns the full system prompt text; `''` when every section renders empty
|
||||
* (the caller then sends no system prompt at all).
|
||||
*/
|
||||
export function renderPrompt(assembly: PromptAssembly): string {
|
||||
return assembly.sections
|
||||
|
||||
@@ -155,6 +155,9 @@ export interface JsonSchemaObject {
|
||||
* `properties`, `required` array).
|
||||
*
|
||||
* This is a plain function — no schemastery or other framework dependency.
|
||||
* @param spec - the author-facing per-property schema to convert.
|
||||
* @returns the wire-format JSON Schema; the top-level `required` array is
|
||||
* omitted entirely when no property is marked required.
|
||||
*/
|
||||
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
|
||||
const properties: Record<string, unknown> = {}
|
||||
@@ -269,6 +272,9 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
|
||||
* keys are allowed (no `additionalProperties: false`); `default` is not
|
||||
* applied; an `object`/`array` prop without `properties`/`items` only
|
||||
* type-checks; `enum` is membership (strings only).
|
||||
* @param spec - the declared parameter schema to validate against.
|
||||
* @param args - the model-generated arguments, however malformed.
|
||||
* @returns the violation messages in declaration order; empty means valid.
|
||||
*/
|
||||
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
|
||||
return checkSpec(spec, args, '')
|
||||
@@ -340,6 +346,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
|
||||
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
|
||||
* first-party plugin authors.
|
||||
* @param options - the tool's name, description, typed parameter schema,
|
||||
* execute body, and optional presenters.
|
||||
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
|
||||
* raw args first (throwing {@link ToolArgsError} on mismatch, which the
|
||||
* registry turns into an isError result), and its presenters validate softly
|
||||
* (returning undefined on mismatch, since replay may feed them older-schema
|
||||
* args).
|
||||
*/
|
||||
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||
|
||||
Reference in New Issue
Block a user