Merge remote-tracking branch 'origin/master' into feat/plan-mode
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/module-graph.md # examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl
This commit is contained in:
@@ -32,6 +32,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
@@ -44,6 +45,6 @@ Groups distinguish product API from support infrastructure. New packages join an
|
||||
|
||||
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
|
||||
|
||||
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
|
||||
|
||||
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).
|
||||
|
||||
@@ -15,7 +15,7 @@ This backend owns the compaction policy:
|
||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-compact-basic/region
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
toolPairingBalancedAfter,
|
||||
toolPairingBalancedBefore,
|
||||
@@ -113,8 +114,8 @@ export async function compactSurfaceRegion(
|
||||
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
|
||||
const startEvent = session.append('compact/start', { turn: tail.turn })
|
||||
try {
|
||||
// Capture after the lock event so any later durable append, including a
|
||||
// log-only one, invalidates the async selection before replacement.
|
||||
// Capture after the lock event so a later surface mutation invalidates the
|
||||
// async selection before replacement. Unrelated log-only facts may append.
|
||||
const lockedMeasurement = dependencies.meter.measure(session)
|
||||
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
|
||||
if (selected.length !== shadowedSeqs.length
|
||||
@@ -126,8 +127,8 @@ export async function compactSurfaceRegion(
|
||||
const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal)
|
||||
|
||||
const currentMeasurement = dependencies.meter.measure(session)
|
||||
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
|
||||
throw new Error('compaction: session log changed during summarization')
|
||||
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
|
||||
throw new Error('compaction: session surface changed during summarization')
|
||||
}
|
||||
const framedSummary = frameSummary(summary)
|
||||
const framedSummaryTokenCount = dependencies.meter.estimateMessage({
|
||||
|
||||
@@ -936,13 +936,13 @@ describe('compaction region transaction', () => {
|
||||
.toMatchObject({ error: 'plain failure' })
|
||||
})
|
||||
|
||||
it('rejects concurrent durable appends before committing the replacement', async () => {
|
||||
it('tolerates concurrent log-only appends while the selected surface is stable', async () => {
|
||||
const compact = service()
|
||||
const session = conversation(2)
|
||||
compact.mutateDuringSummary = () => {
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
reason: 'change',
|
||||
})
|
||||
}
|
||||
const nodes = session.surface.nodes
|
||||
@@ -951,7 +951,26 @@ describe('compaction region transaction', () => {
|
||||
nodes[0]!,
|
||||
nodes[2]!,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow(/session log changed/)
|
||||
)).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) })
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects concurrent surface appends before committing the replacement', async () => {
|
||||
const compact = service()
|
||||
const session = conversation(2)
|
||||
compact.mutateDuringSummary = () => {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'concurrent surface mutation' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
const nodes = session.surface.nodes
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
nodes[0]!,
|
||||
nodes[2]!,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow(/session surface changed/)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is
|
||||
|
||||
## Blocking
|
||||
|
||||
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
|
||||
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
|
||||
|
||||
## Events
|
||||
|
||||
|
||||
@@ -439,6 +439,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'listSessions(): Promise<SessionRecord[]>',
|
||||
jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined>',
|
||||
jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
|
||||
jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */',
|
||||
@@ -481,6 +485,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'async flush(session: Session): Promise<void>',
|
||||
jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async appendOutOfBand<T extends OutOfBandSessionEventType>( session: Session, type: T, data: SessionEventMap[T], trigger: TurnTrigger, ): Promise<SessionEvent<T>>',
|
||||
jsDoc: '/**\n * Append one plugin-declared log-only event without borrowing the agent\n * loop\'s lifecycle. An open turn receives the event directly and remains\n * responsible for its ordinary checkpoint. A closed log receives one\n * zero-step turn around the event, followed by an awaited flush.\n *\n * Once the synthetic `turn/start` commits, this method always attempts its\n * matching `turn/end` and flush, including when the target append fails.\n * Detachment requested by an event or flush listener is deferred until that\n * sequence settles, so publication cannot switch from a live scoped session\n * to an unobserved bare `Session` halfway through the update.\n *\n * @param session - exact live session that owns the target log.\n * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.\n * @param data - typed JSON payload for the target event.\n * @param trigger - plugin-owned turn trigger used only when the log is closed.\n * @returns the accepted target event with its assigned sequence and timestamp.\n * @throws when the session is detached, another out-of-band append is active,\n * event acceptance fails, the synthetic turn cannot close, or flushing fails.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(id: SessionId): Session | undefined',
|
||||
jsDoc: '/**\n * Look up a live session.\n * @param id - the session id to look up.\n * @returns the session, or undefined when no live session has that id.\n */',
|
||||
@@ -495,6 +503,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionTitle',
|
||||
summary: 'Log-backed title fold plus asynchronous fallback generation.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'get(session: Session): SessionTitleSnapshot | undefined',
|
||||
jsDoc: '/**\n * Read the latest folded title from one live or replayed session.\n * @param session - session whose log is the title source of truth.\n * @returns latest title snapshot, or `undefined` before eligible input.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined>',
|
||||
jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'register(provider: SessionTitleProvider): () => Promise<void>',
|
||||
jsDoc: '/**\n * Register the sole optional title provider. Disposal aborts its pending and\n * active work before another provider may register.\n * @param provider - provider identity, cadence, and generation function.\n * @returns exact Cordis effect disposer, which settles after active calls quiesce.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
summary: 'Registry of skill providers.',
|
||||
@@ -890,7 +916,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'llm/stream',
|
||||
mode: 'waterfall',
|
||||
signature: '\'llm/stream\'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>',
|
||||
jsDoc: '/**\n * Waterfall around every streaming model call (retry, replay, routing).\n * Bound to the {@link LlmService}; call `next()` to reach the resolved\n * adapter\'s stream, or yield your own chunks to short-circuit.\n * @param options - the full request. A LOOP-built request arrives\n * deep-frozen (mutation throws): its content is a pure function of the\n * session log (the reconstructability Agent Note), so listeners read it, never\n * rewrite it. A hand-built one-shot (compaction summarize) is the\n * caller\'s own object and stays mutable here.\n * @mode waterfall\n */',
|
||||
jsDoc: '/**\n * Waterfall around every streaming model call (retry, replay, routing).\n * Bound to the {@link LlmService}; call `next()` to reach the resolved\n * adapter\'s stream, or yield your own chunks to short-circuit.\n * @param options - the full request. A LOOP-built request carries the\n * process-local {@link markAgentLoopRequest} identity and arrives deep-frozen\n * (mutation throws): its content is a pure function of the session log (the\n * reconstructability Agent Note), so listeners read it, never rewrite it.\n * Hand-built calls own their mutability policy and do not carry that marker.\n * @mode waterfall\n */',
|
||||
summary: 'Waterfall around every streaming model call (retry, replay, routing).',
|
||||
},
|
||||
{
|
||||
@@ -1416,6 +1442,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'MessageSourceMap',
|
||||
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'OutOfBandSessionEventMap',
|
||||
declaration: 'export interface OutOfBandSessionEventMap {\n}',
|
||||
},
|
||||
{
|
||||
name: 'OutOfBandSessionEventType',
|
||||
declaration: 'export type OutOfBandSessionEventType = Exclude<Extract<SessionEventType, keyof OutOfBandSessionEventMap>, SurfaceEventType>;',
|
||||
},
|
||||
{
|
||||
name: 'PresetOption',
|
||||
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
|
||||
@@ -1540,6 +1574,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionRecord',
|
||||
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleAutomaticMode',
|
||||
declaration: 'export type SessionTitleAutomaticMode = \'first-message\' | \'all-user-messages\';',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleEventData',
|
||||
declaration: 'export interface SessionTitleEventData {\n readonly title: string;\n readonly messageSeqs: number[];\n readonly source: SessionTitleSource;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleModelProvenance',
|
||||
declaration: 'export interface SessionTitleModelProvenance {\n readonly provider: string;\n readonly model: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleProvider',
|
||||
declaration: 'export interface SessionTitleProvider {\n readonly id: SessionTitleProviderId;\n readonly automatic: SessionTitleAutomaticMode;\n generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleProviderId',
|
||||
declaration: 'export type SessionTitleProviderId = Branded<\'SessionTitleProviderId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleProviderRequest',
|
||||
declaration: 'export interface SessionTitleProviderRequest {\n readonly session: Session;\n readonly messages: readonly SessionTitleUserMessage[];\n readonly route?: SessionTitleModelProvenance;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleProviderResult',
|
||||
declaration: 'export interface SessionTitleProviderResult {\n readonly title: string;\n readonly messageSeqs: readonly number[];\n readonly model?: SessionTitleModelProvenance;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleSnapshot',
|
||||
declaration: 'export interface SessionTitleSnapshot extends SessionTitleEventData {\n readonly eventSeq: number;\n readonly updatedAt: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleSource',
|
||||
declaration: 'export type SessionTitleSource = {\n readonly kind: \'fallback\';\n} | {\n readonly kind: \'provider\';\n readonly provider: SessionTitleProviderId;\n readonly model?: SessionTitleModelProvenance;\n};',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleUserMessage',
|
||||
declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillCandidate',
|
||||
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
|
||||
|
||||
@@ -15,4 +15,4 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
|
||||
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable.
|
||||
|
||||
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
|
||||
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + fallback session titles + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
|
||||
|
||||
@@ -29,7 +29,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
|
||||
|
||||
### Invariant companion
|
||||
|
||||
The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop marks each request with an internal non-enumerable identity before freezing it; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id.
|
||||
The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop records each exact frozen request in the process-local identity set owned by `dsh-llm`; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id.
|
||||
|
||||
### Configuration (schemastery)
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import { isLoopRequest } from './request-marker.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
@@ -21,7 +20,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
// Prepend prevents a short-circuiting replay listener from silencing the
|
||||
// check; correctness itself comes from the sequence-bounded reconstruction.
|
||||
ctx.on('llm/stream', (options: GenerateOptions, next) => {
|
||||
if (!isLoopRequest(options)) return next()
|
||||
if (!isAgentLoopRequest(options)) return next()
|
||||
if (!Object.isFrozen(options)) fail('a loop-built request must be frozen')
|
||||
if (options.sessionId === undefined) fail('a loop-built request must carry a session id')
|
||||
const session = ctx.sessions.get(options.sessionId)
|
||||
|
||||
@@ -8,14 +8,13 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
import type { TransmissionLog } from './request-log.ts'
|
||||
import { markLoopRequest } from './request-marker.ts'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -598,7 +597,7 @@ async function runStep(
|
||||
recordRequestHeader(session, transmission, header)
|
||||
|
||||
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
|
||||
const request: GenerateOptions = deepFreeze(markLoopRequest({
|
||||
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
|
||||
provider: header.config.provider,
|
||||
model: header.config.model,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/** Internal identity shared by the independently bundled loop and invariant companion. */
|
||||
|
||||
const LOOP_REQUEST = Symbol.for('@deepseek-ai/dsh-agent-loop/request')
|
||||
|
||||
/**
|
||||
* Mark a request as owned by the agent loop before it is frozen.
|
||||
* @param request - mutable request object being assembled by the loop.
|
||||
* @returns the same request with a non-enumerable loop identity.
|
||||
*/
|
||||
export function markLoopRequest<T extends object>(request: T): T {
|
||||
Object.defineProperty(request, LOOP_REQUEST, { value: true })
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a request carries the agent loop's internal identity.
|
||||
* @param request - request observed at the LLM stream boundary.
|
||||
* @returns whether the loop marked this exact request object.
|
||||
*/
|
||||
export function isLoopRequest(request: object): boolean {
|
||||
return Reflect.get(request, LOOP_REQUEST) === true
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import { markLoopRequest } from '../src/request-marker.ts'
|
||||
import { markAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
@@ -18,7 +18,8 @@ function dispatch(ctx: Context, options: unknown): void {
|
||||
}
|
||||
|
||||
function loopRequest<T extends object>(options: T): Readonly<T> {
|
||||
return Object.freeze(markLoopRequest(options))
|
||||
markAgentLoopRequest(options as GenerateOptions)
|
||||
return Object.freeze(options)
|
||||
}
|
||||
|
||||
async function requestSetup() {
|
||||
@@ -94,8 +95,10 @@ describe('request-reconstruction invariant', () => {
|
||||
|
||||
it('rejects malformed requests carrying the loop marker', async () => {
|
||||
const { ctx, session } = await requestSetup()
|
||||
const messages: GenerateOptions['messages'] = []
|
||||
Object.freeze(messages)
|
||||
expect(() => {
|
||||
dispatch(ctx, markLoopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id }))
|
||||
dispatch(ctx, markAgentLoopRequest({ provider: 'p', model: 'm', messages, sessionId: session.id }))
|
||||
}).toThrow(/request must be frozen/)
|
||||
expect(() => {
|
||||
dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) }))
|
||||
|
||||
@@ -12,6 +12,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles.
|
||||
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
@@ -64,7 +66,7 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
|
||||
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog.
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. `OutOfBandSessionEventMap` is a separate empty-by-default marker map: an event owner must merge the same key there before `appendOutOfBand()` accepts that log-only type, while surface and lifecycle types remain excluded.
|
||||
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -27,6 +27,27 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed message-triggered turn, excluding injection and
|
||||
* plugin-owned zero-step turns.
|
||||
* @param events - session events, or an owned suffix, to inspect.
|
||||
* @returns the latest matching turn end, or `undefined`.
|
||||
*/
|
||||
export function findLastMessageTurnEnd(
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEvent<'turn/end'> | undefined {
|
||||
const messageTurns = new Set<number>()
|
||||
let latest: SessionEvent<'turn/end'> | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'turn/start') {
|
||||
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessions: SessionStore
|
||||
@@ -227,6 +248,7 @@ interface SessionEntry {
|
||||
announced: boolean
|
||||
announcing: boolean
|
||||
appending: boolean
|
||||
outOfBand: boolean
|
||||
detachRequested: boolean
|
||||
detach(): void
|
||||
}
|
||||
@@ -401,7 +423,7 @@ export class Session {
|
||||
} finally {
|
||||
if (entry !== undefined) {
|
||||
entry.appending = false
|
||||
if (entry.detachRequested && !entry.announcing) entry.detach()
|
||||
if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,6 +707,7 @@ export class SessionStore extends Service {
|
||||
announced: false,
|
||||
announcing: false,
|
||||
appending: false,
|
||||
outOfBand: false,
|
||||
detachRequested: false,
|
||||
detach: () => { this.detachEntered(entry) },
|
||||
}
|
||||
@@ -697,7 +720,7 @@ export class SessionStore extends Service {
|
||||
// A lifecycle listener may own the advanced detach capability. Keep the
|
||||
// entry and its publication hooks live until synchronous creation or append
|
||||
// publication unwinds, then publish the paired disposal edge.
|
||||
if (entry.announcing || entry.appending) {
|
||||
if (entry.announcing || entry.appending || entry.outOfBand) {
|
||||
entry.detachRequested = true
|
||||
return
|
||||
}
|
||||
@@ -751,7 +774,7 @@ export class SessionStore extends Service {
|
||||
}
|
||||
} finally {
|
||||
entry.announcing = false
|
||||
if (entry.detachRequested && !entry.appending) entry.detach()
|
||||
if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,6 +818,87 @@ export class SessionStore extends Service {
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one plugin-declared log-only event without borrowing the agent
|
||||
* loop's lifecycle. An open turn receives the event directly and remains
|
||||
* responsible for its ordinary checkpoint. A closed log receives one
|
||||
* zero-step turn around the event, followed by an awaited flush.
|
||||
*
|
||||
* Once the synthetic `turn/start` commits, this method always attempts its
|
||||
* matching `turn/end` and flush, including when the target append fails.
|
||||
* Detachment requested by an event or flush listener is deferred until that
|
||||
* sequence settles, so publication cannot switch from a live scoped session
|
||||
* to an unobserved bare `Session` halfway through the update.
|
||||
*
|
||||
* @param session - exact live session that owns the target log.
|
||||
* @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.
|
||||
* @param data - typed JSON payload for the target event.
|
||||
* @param trigger - plugin-owned turn trigger used only when the log is closed.
|
||||
* @returns the accepted target event with its assigned sequence and timestamp.
|
||||
* @throws when the session is detached, another out-of-band append is active,
|
||||
* event acceptance fails, the synthetic turn cannot close, or flushing fails.
|
||||
*/
|
||||
async appendOutOfBand<T extends OutOfBandSessionEventType>(
|
||||
session: Session,
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
trigger: TurnTrigger,
|
||||
): Promise<SessionEvent<T>> {
|
||||
const entry = this.liveEntryFor(session)
|
||||
if (entry.outOfBand) {
|
||||
throw new Error(`session "${session.id}" already has an out-of-band append in progress`)
|
||||
}
|
||||
entry.outOfBand = true
|
||||
// `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but
|
||||
// TypeScript does not reduce Session.append's conditional rest parameter
|
||||
// through a generic intersection. Preserve that proven two-argument call
|
||||
// shape without widening the public Session.append overload.
|
||||
const appendLogOnly = session.append.bind(session) as unknown as <K extends OutOfBandSessionEventType>(
|
||||
eventType: K,
|
||||
eventData: SessionEventMap[K],
|
||||
) => SessionEvent<K>
|
||||
try {
|
||||
const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
|
||||
if (lastBoundary?.type === 'turn/start') {
|
||||
return appendLogOnly(type, data)
|
||||
}
|
||||
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
const turn = (lastStart?.data.turn ?? 0) + 1
|
||||
let accepted: SessionEvent<T> | undefined
|
||||
let failure: unknown
|
||||
let opened = false
|
||||
try {
|
||||
session.append('turn/start', { turn, trigger })
|
||||
opened = true
|
||||
accepted = appendLogOnly(type, data)
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
} finally {
|
||||
if (opened) {
|
||||
// The only target types admitted by OutOfBandSessionEventMap are
|
||||
// log-only plugin events, so the synthetic turn remains open here.
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
try {
|
||||
await this.flush(session)
|
||||
} catch (error: unknown) {
|
||||
if (failure === undefined) failure = error
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly
|
||||
throw failure
|
||||
}
|
||||
/* v8 ignore next -- accepted is assigned unless an append failure was captured above. */
|
||||
if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event')
|
||||
return accepted
|
||||
} finally {
|
||||
entry.outOfBand = false
|
||||
if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach()
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the exact live entry; detached/prepared objects reject. */
|
||||
private liveEntryFor(session: Session): SessionEntry {
|
||||
const entry = attachments.get(session)
|
||||
|
||||
@@ -264,9 +264,23 @@ export interface SessionEventMap {
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker map for plugin-owned log-only events accepted by
|
||||
* `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key
|
||||
* it adds to {@link SessionEventMap}; surface and lifecycle events stay
|
||||
* ineligible unless their owner explicitly opts them into this narrow seam.
|
||||
*/
|
||||
export interface OutOfBandSessionEventMap {}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
export type SessionEventType = keyof SessionEventMap
|
||||
|
||||
/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */
|
||||
export type OutOfBandSessionEventType = Exclude<
|
||||
Extract<SessionEventType, keyof OutOfBandSessionEventMap>,
|
||||
SurfaceEventType
|
||||
>
|
||||
|
||||
/**
|
||||
* The subset of {@link SessionEventType} values whose events produce LLM
|
||||
* messages and are eligible to appear on the ordered surface. Only these
|
||||
|
||||
226
packages/core/session/tests/out-of-band.spec.ts
Normal file
226
packages/core/session/tests/out-of-band.spec.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
'test/log-only': { value: string }
|
||||
}
|
||||
|
||||
interface OutOfBandSessionEventMap {
|
||||
'test/log-only': true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const
|
||||
|
||||
describe('SessionStore.appendOutOfBand', () => {
|
||||
it('joins an open turn without adding a boundary or flushing it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('open'))
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
const event = await ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'inside' },
|
||||
updateTrigger,
|
||||
)
|
||||
|
||||
expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } })
|
||||
expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only'])
|
||||
expect(flushes).toBe(0)
|
||||
})
|
||||
|
||||
it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('closed'))
|
||||
const flushedTypes: string[][] = []
|
||||
ctx.on('session/flush', (flushed) => {
|
||||
flushedTypes.push(flushed.events.map(event => event.type))
|
||||
})
|
||||
|
||||
const first = await ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'first' },
|
||||
updateTrigger,
|
||||
)
|
||||
const second = await ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'second' },
|
||||
updateTrigger,
|
||||
)
|
||||
|
||||
expect(first.seq).toBe(1)
|
||||
expect(second.seq).toBe(4)
|
||||
expect(session.events).toMatchObject([
|
||||
{ type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } },
|
||||
{ type: 'test/log-only', seq: 1, data: { value: 'first' } },
|
||||
{ type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } },
|
||||
{ type: 'test/log-only', seq: 4, data: { value: 'second' } },
|
||||
{ type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
])
|
||||
expect(flushedTypes).toEqual([
|
||||
['turn/start', 'test/log-only', 'turn/end'],
|
||||
['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'],
|
||||
])
|
||||
})
|
||||
|
||||
it('closes and flushes a zero-step turn when the target event is rejected', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('rejected'))
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 1n } as never,
|
||||
updateTrigger,
|
||||
)).rejects.toThrow(/non-JSON-serializable/)
|
||||
|
||||
expect(session.events).toMatchObject([
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
])
|
||||
expect(flushes).toBe(1)
|
||||
})
|
||||
|
||||
it('does not flush when the synthetic turn cannot open', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('start-failure'))
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'unreachable' },
|
||||
{ ...updateTrigger, invalid: 1n } as never,
|
||||
)).rejects.toThrow(/non-JSON-serializable/)
|
||||
|
||||
expect(session.events).toEqual([])
|
||||
expect(flushes).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves a target rejection when the balancing flush also rejects', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('target-and-flush-failure'))
|
||||
ctx.on('session/flush', () => { throw new Error('disk failed') })
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 1n } as never,
|
||||
updateTrigger,
|
||||
)).rejects.toThrow(/non-JSON-serializable/)
|
||||
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'turn/end',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the session attached through publication and its flush', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.prepare(SessionId('dispose'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
let liveDuringFlush = false
|
||||
ctx.on('session/event', (_observed, event) => {
|
||||
if (event.type === 'turn/start') detach()
|
||||
})
|
||||
ctx.on('session/flush', () => {
|
||||
liveDuringFlush = ctx.sessions.get(session.id) === session
|
||||
})
|
||||
|
||||
await ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'last' },
|
||||
updateTrigger,
|
||||
)
|
||||
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'test/log-only',
|
||||
'turn/end',
|
||||
])
|
||||
expect(liveDuringFlush).toBe(true)
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects detached sessions before opening a turn', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.prepare(SessionId('detached'))
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'nope' },
|
||||
updateTrigger,
|
||||
)).rejects.toThrow('session "detached" is not live in this store')
|
||||
expect(session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a balanced log when the durability checkpoint rejects', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('flush-failure'))
|
||||
ctx.on('session/flush', () => { throw new Error('disk failed') })
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'accepted' },
|
||||
updateTrigger,
|
||||
)).rejects.toThrow('disk failed')
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'test/log-only',
|
||||
'turn/end',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects overlapping updates while the first append is still settling', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('overlap'))
|
||||
let release!: () => void
|
||||
const checkpoint = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
ctx.on('session/flush', () => checkpoint)
|
||||
|
||||
const first = ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'first' },
|
||||
updateTrigger,
|
||||
)
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'overlap' },
|
||||
updateTrigger,
|
||||
)).rejects.toThrow(/out-of-band append in progress/)
|
||||
release()
|
||||
await expect(first).resolves.toMatchObject({ data: { value: 'first' } })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, {
|
||||
findLastMessageTurnEnd,
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
@@ -48,6 +54,42 @@ describe('Session', () => {
|
||||
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
||||
})
|
||||
|
||||
it('finds the latest message-turn outcome past later non-message turns', () => {
|
||||
const session = new Session(SessionId('message-turn-outcome'))
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'before' }],
|
||||
source: { kind: 'plugin', plugin: 'before' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'bounded prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 3,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'after' }],
|
||||
source: { kind: 'plugin', plugin: 'after' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
||||
|
||||
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
|
||||
})
|
||||
|
||||
it('round-trips the coarse aborted turn outcome', () => {
|
||||
const session = new Session(SessionId('aborted'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
|
||||
@@ -4,7 +4,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|
||||
|
||||
| Package | npm name | Role |
|
||||
|---|---|---|
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with an opt-in persisted-goal stack |
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
|
||||
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
|
||||
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
|
||||
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
|
||||
|
||||
@@ -33,6 +33,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
|
||||
| `sessionTitle` | spine example limits | fallback title word/byte limits routed through `dsh-agent-spine-demo` |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
|
||||
| `workspaceContext` | (required) | workspace-instruction byte budget/config, or `false`; routed to the providerless-safe `dsh-workspace-context` plugin |
|
||||
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface Config {
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Fallback session-title limits forwarded through agent-spine-demo. */
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
@@ -82,6 +84,7 @@ export const Config: z<Config> = z.object({
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
sessionTitle: agentCore.SessionTitleConfigSchema,
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
|
||||
@@ -12,6 +12,7 @@ Read this package for the whole plugin tree and its composition order.
|
||||
@cordisjs/plugin-timer timer service (writes nothing to stdout)
|
||||
@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary
|
||||
@deepseek-ai/dsh-session event-sourced session log + store
|
||||
@deepseek-ai/dsh-session-title log-backed title service + deterministic fallback
|
||||
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
|
||||
@deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline
|
||||
@deepseek-ai/dsh-skill skill provider registry
|
||||
@@ -41,6 +42,7 @@ Read this package for the whole plugin tree and its composition order.
|
||||
The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle:
|
||||
|
||||
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
|
||||
- **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider.
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **presentation + per-app infra** — the terminal TUI or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
|
||||
@@ -51,11 +53,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? }
|
||||
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? }
|
||||
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
|
||||
|
||||
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-spine-demo",
|
||||
"description": "The default executor-less/UI-less agent spine with bounded retry and optional persisted goals",
|
||||
"description": "The default executor-less/UI-less agent spine with fallback session titles, bounded retry, and optional persisted goals",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -38,6 +38,7 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
@@ -63,6 +64,7 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -13,6 +13,7 @@ import Timer from '@cordisjs/plugin-timer'
|
||||
import z from 'schemastery'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
|
||||
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
|
||||
@@ -37,6 +38,13 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
export const name = 'agent-spine-demo'
|
||||
|
||||
/** Overridable example policy used when a bundle consumer omits `sessionTitle`. */
|
||||
const EXAMPLE_SESSION_TITLE_CONFIG: SessionTitleConfig = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
}
|
||||
|
||||
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
|
||||
export interface SkillConfig {
|
||||
/** Mount the bundled local skill provider and model-facing skill tool (default true). */
|
||||
@@ -63,7 +71,8 @@ export interface GoalConfig {
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`),
|
||||
* `dshHome` to bash environment and local skill discovery, `skills` to the
|
||||
* `dshHome` to bash environment and local skill discovery, `sessionTitle` to
|
||||
* the fallback title service, `skills` to the
|
||||
* skill registry/local provider/tool consumer, `workspaceContext` to the
|
||||
* workspace-context loader, `llmRetry` to the bounded request-recovery policy,
|
||||
* and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
|
||||
@@ -88,6 +97,8 @@ export interface Config {
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory shared by shell context and local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Deterministic fallback and accepted-title limits; omission uses the bundle's example policy. */
|
||||
sessionTitle?: SessionTitleConfig
|
||||
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
|
||||
workspaceContext: workspaceContext.Config | false
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
@@ -112,6 +123,10 @@ export const SkillConfigSchema: z<SkillConfig> = z.object({
|
||||
tool: toolSkill.Config,
|
||||
})
|
||||
|
||||
/** The session-title config schema with the shared bundle's overridable example limits. */
|
||||
export const SessionTitleConfigSchema: z<SessionTitleConfig> = SessionTitleService.Config
|
||||
.default(EXAMPLE_SESSION_TITLE_CONFIG)
|
||||
|
||||
/** The bash-tool config schema exported for app packages that forward `toolBash`. */
|
||||
export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
|
||||
|
||||
@@ -134,6 +149,7 @@ export const Config = z.intersect([
|
||||
z.object({
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
sessionTitle: SessionTitleConfigSchema,
|
||||
skills: SkillConfigSchema,
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
toolBash: ToolBashConfigSchema,
|
||||
@@ -141,7 +157,7 @@ export const Config = z.intersect([
|
||||
invariants: InvariantService.Config,
|
||||
goals: z.union([z.const(false), GoalConfigSchema]),
|
||||
llmRetry: LlmRetryConfigSchema,
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants' | 'goals' | 'llmRetry'>>,
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'sessionTitle' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants' | 'goals' | 'llmRetry'>>,
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
@@ -156,6 +172,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
...config.dshHome !== undefined ? { dshHome: config.dshHome } : {},
|
||||
...config.sessionTitle !== undefined ? { sessionTitle: config.sessionTitle } : {},
|
||||
workspaceContext: config.workspaceContext,
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
|
||||
@@ -187,6 +204,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(Timer)
|
||||
ctx.plugin(LlmService)
|
||||
ctx.plugin(SessionStore)
|
||||
ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG)
|
||||
// Owner schemas resolve defaults; forward toolOrder only when explicitly set.
|
||||
ctx.plugin(SystemPrompt, {
|
||||
persona: config.persona ?? '',
|
||||
|
||||
@@ -131,6 +131,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
expect(ctx.get('timer')).toBeDefined()
|
||||
expect(ctx.get('llm')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionTitle')).toBeDefined()
|
||||
expect(ctx.get('systemPrompt')).toBeDefined()
|
||||
expect(ctx.get('tools')).toBeDefined()
|
||||
expect(ctx.get('skills')).toBeDefined()
|
||||
@@ -142,6 +143,30 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards configurable fallback title limits to the bundled service', async () => {
|
||||
const ctx = await mount({
|
||||
workspaceContext: false,
|
||||
sessionTitle: {
|
||||
fallbackMaxWords: 1,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('configured-title-limits'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'One two three four' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe('One')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => {
|
||||
const ctx = await mount({
|
||||
workspaceContext: false,
|
||||
@@ -218,6 +243,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
expect(retryEvents).toHaveLength(1)
|
||||
expect(retryEvents[0]?.data.retry).toBe(1)
|
||||
expect(retryEvents[0]?.data.maxRetries).toBe(1)
|
||||
expect(handle.agent.session.events.find(event => event.type === 'session/title')?.data.title).toBe('recover')
|
||||
expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy')
|
||||
await handle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -491,6 +517,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
toolOrder: ['zulu'],
|
||||
tools: { mode: 'native' as const },
|
||||
dshHome: '/tmp/dsh-home',
|
||||
sessionTitle: { fallbackMaxWords: 3, fallbackMaxBytes: 24, maxTitleBytes: 60 },
|
||||
workspaceContext: false as const,
|
||||
skills: { enabled: false },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
@@ -504,6 +531,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
toolOrder: appConfig.toolOrder,
|
||||
tools: appConfig.tools,
|
||||
dshHome: appConfig.dshHome,
|
||||
sessionTitle: appConfig.sessionTitle,
|
||||
workspaceContext: false,
|
||||
skills: appConfig.skills,
|
||||
toolBash: appConfig.toolBash,
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ The package mounts no console logger, interactive UI, user-interaction service,
|
||||
| `toolOrder` | lexicographic | explicit model-facing tool order in `dsh-system-prompt` |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `dsh-agent-spine-demo` |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
|
||||
| `sessionTitle` | spine example limits | Fallback title word/byte limits through `dsh-agent-spine-demo` |
|
||||
| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool |
|
||||
| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds |
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface Config {
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Fallback session-title limits forwarded through agent-spine-demo. */
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
@@ -64,6 +66,7 @@ export const Config: z<Config> = z.object({
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
persona: z.string(),
|
||||
dshHome: z.string(),
|
||||
sessionTitle: agentCore.SessionTitleConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
// Absent means lexicographic order; schemastery's native array default is [].
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
|
||||
@@ -29,6 +29,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
|
||||
| `toolOrder` | lexicographic | Explicit model-facing tool order |
|
||||
| `tools` | owner default | Tool presentation mode |
|
||||
| `dshHome` | owner default | Harness home used by bash and skills |
|
||||
| `sessionTitle` | spine example limits | Fallback title word/byte limits |
|
||||
| `skills` | owner defaults | Skill registry, local provider, and tool config |
|
||||
| `toolBash` | owner defaults | Model-facing bash tool config |
|
||||
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface Config {
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Fallback session-title limits forwarded through agent-spine-demo. */
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
@@ -79,6 +81,7 @@ export const Config: z<Config> = z.object({
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
sessionTitle: agentCore.SessionTitleConfigSchema,
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
welcome: z.string().default(DEFAULT_WELCOME),
|
||||
|
||||
@@ -21,7 +21,7 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def
|
||||
|
||||
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
|
||||
|
||||
One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle.
|
||||
One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. The driver pairs a reservation only with a `message` turn carrying its exact `GoalMessageSource`; merge-extensible plugin turn triggers do not admit or replace that reservation. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle.
|
||||
|
||||
The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`.
|
||||
|
||||
|
||||
@@ -345,11 +345,17 @@ export function apply(ctx: Context): void {
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
state.openTurn = event.data.turn
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source)
|
||||
&& sameRound(event.data.trigger.source, state.attempt)) {
|
||||
state.attempt.turn = event.data.turn
|
||||
switch (event.data.trigger.kind) {
|
||||
case 'message':
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source)
|
||||
&& sameRound(event.data.trigger.source, state.attempt)) {
|
||||
state.attempt.turn = event.data.turn
|
||||
}
|
||||
return
|
||||
default:
|
||||
// Injection and merge-extensible plugin triggers cannot admit a queued goal message.
|
||||
return
|
||||
}
|
||||
return
|
||||
case 'user/message':
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.source)
|
||||
&& sameRound(event.data.source, state.attempt)) {
|
||||
|
||||
@@ -12,6 +12,13 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import * as goalSession from '../src/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface TurnTriggerMap {
|
||||
/** Test-only plugin turn with no message source. */
|
||||
'test-metadata': { kind: 'test-metadata' }
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
|
||||
|
||||
/** Small request-recording adapter with controllable failure and cancellation. */
|
||||
@@ -322,6 +329,31 @@ describe('same-session goal driving', () => {
|
||||
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
|
||||
})
|
||||
|
||||
it('ignores plugin-owned turn triggers while a goal round is queued', async () => {
|
||||
const test = await harness([textResponse('goal answer')])
|
||||
const warnings: string[] = []
|
||||
test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn
|
||||
let inserted = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
const lastStart = agent.session.events.findLast(event => event.type === 'turn/start')
|
||||
const turn = (lastStart?.data.turn ?? 0) + 1
|
||||
agent.session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'test-metadata' },
|
||||
})
|
||||
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'ignore metadata', maxGoalRounds: 1 })
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(inserted).toBe(true)
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
expect(warnings.some(warning => warning.includes('session/event listener threw'))).toBe(false)
|
||||
})
|
||||
|
||||
it('makes a reserved round stale when a listener queues human work behind it', async () => {
|
||||
const test = await harness([textResponse('human batch'), textResponse('later goal')])
|
||||
let inserted = false
|
||||
@@ -436,8 +468,10 @@ describe('same-session goal driving', () => {
|
||||
expect(goal?.phase).toBe('active')
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
const turns = test.agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const goalTurn = turns.findIndex(event => event.data.trigger.source.kind === 'goal')
|
||||
const injectedTurn = turns.findIndex(event => event.data.trigger.source.kind === 'plugin')
|
||||
const goalTurn = turns.findIndex(event => event.data.trigger.kind === 'message'
|
||||
&& event.data.trigger.source.kind === 'goal')
|
||||
const injectedTurn = turns.findIndex(event => event.data.trigger.kind === 'injection'
|
||||
&& event.data.trigger.source.kind === 'plugin')
|
||||
expect(injectedTurn).toBeGreaterThan(goalTurn)
|
||||
})
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite).
|
||||
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated.
|
||||
|
||||
### App attribution (`attribution.ts`)
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
* @module dsh-llm/call-config
|
||||
*/
|
||||
|
||||
import type { GenerateOptions } from './types.ts'
|
||||
|
||||
/** Process-local identities of request objects assembled by dsh-agent-loop. */
|
||||
const AGENT_LOOP_REQUESTS = new WeakSet<GenerateOptions>()
|
||||
|
||||
/**
|
||||
* Provider + model + sampling scalars of one conversation's requests. Every field maps
|
||||
* 1:1 onto the same-named `GenerateOptions` field; the loop builds requests
|
||||
@@ -33,6 +38,25 @@ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean {
|
||||
return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one exact request object as assembled by dsh-agent-loop.
|
||||
* @param request - loop-owned request envelope before LLM dispatch.
|
||||
* @returns the same request object with process-local loop provenance.
|
||||
*/
|
||||
export function markAgentLoopRequest<T extends GenerateOptions>(request: T): T {
|
||||
AGENT_LOOP_REQUESTS.add(request)
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the exact request object was assembled by dsh-agent-loop.
|
||||
* @param request - request envelope observed at the LLM waterfall.
|
||||
* @returns whether {@link markAgentLoopRequest} recorded this object.
|
||||
*/
|
||||
export function isAgentLoopRequest(request: GenerateOptions): boolean {
|
||||
return AGENT_LOOP_REQUESTS.has(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-freeze a value in place, guarding cycles, so later mutation throws.
|
||||
* {@link AbortSignal} objects are deliberately skipped because they are the
|
||||
|
||||
@@ -28,7 +28,7 @@ export * from './never.ts'
|
||||
export * from './error.ts'
|
||||
export * from './types.ts'
|
||||
export { BlockAssembler } from './assembler.ts'
|
||||
export { callConfigEquals, deepFreeze } from './call-config.ts'
|
||||
export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
|
||||
export type { LlmCallConfig } from './call-config.ts'
|
||||
export { isLlmAdapterFailure, llmFailureOf } from './adapter-failure.ts'
|
||||
|
||||
@@ -42,11 +42,11 @@ declare module 'cordis' {
|
||||
* Waterfall around every streaming model call (retry, replay, routing).
|
||||
* Bound to the {@link LlmService}; call `next()` to reach the resolved
|
||||
* adapter's stream, or yield your own chunks to short-circuit.
|
||||
* @param options - the full request. A LOOP-built request arrives
|
||||
* deep-frozen (mutation throws): its content is a pure function of the
|
||||
* session log (the reconstructability Agent Note), so listeners read it, never
|
||||
* rewrite it. A hand-built one-shot (compaction summarize) is the
|
||||
* caller's own object and stays mutable here.
|
||||
* @param options - the full request. A LOOP-built request carries the
|
||||
* process-local {@link markAgentLoopRequest} identity and arrives deep-frozen
|
||||
* (mutation throws): its content is a pure function of the session log (the
|
||||
* reconstructability Agent Note), so listeners read it, never rewrite it.
|
||||
* Hand-built calls own their mutability policy and do not carry that marker.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { callConfigEquals, deepFreeze } from '../src/call-config.ts'
|
||||
import { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from '../src/call-config.ts'
|
||||
import type { GenerateOptions } from '../src/types.ts'
|
||||
|
||||
describe('callConfigEquals', () => {
|
||||
it('compares every field, including the stop list element-wise', () => {
|
||||
@@ -56,3 +57,19 @@ describe('deepFreeze', () => {
|
||||
expect(Object.isFrozen(cyclic)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent-loop request identity', () => {
|
||||
it('marks only the exact request object and preserves its identity', () => {
|
||||
const request: GenerateOptions = {
|
||||
provider: 'mock',
|
||||
model: 'model',
|
||||
messages: [],
|
||||
}
|
||||
const copy = { ...request }
|
||||
|
||||
expect(isAgentLoopRequest(request)).toBe(false)
|
||||
expect(markAgentLoopRequest(request)).toBe(request)
|
||||
expect(isAgentLoopRequest(request)).toBe(true)
|
||||
expect(isAgentLoopRequest(copy)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# session-query/ — session retrieval capability family
|
||||
|
||||
Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, bounded event reads, lineage, and direct event relationships.
|
||||
Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, title folding, surface classification, bounded event reads, lineage, and direct event relationships.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus exact-read and relationship-tracing service | `ctx.sessionQuery` |
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus title, event, lineage, and relationship reads | `ctx.sessionQuery` |
|
||||
|
||||
The family is independent of compaction: it reads canonical lineage, surface operations, and logged provenance but does not participate in compaction policy or execution. Full-text search remains a proposed SQLite package rather than a speculative provider seam in this interface package.
|
||||
|
||||
@@ -5,12 +5,13 @@ Exact session-history retrieval and relationship tracing through `ctx.sessionQue
|
||||
## Reads
|
||||
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
|
||||
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
|
||||
- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
|
||||
|
||||
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations.
|
||||
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles.
|
||||
|
||||
`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -45,6 +46,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionEventReadRequest,
|
||||
SessionEventRecord,
|
||||
@@ -64,6 +66,16 @@ export class SessionQueryService extends Service {
|
||||
return this._corpus.listSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the latest log-backed title from one live-preferred logical session.
|
||||
* @param sessionId - live or persisted session id to read.
|
||||
* @returns latest title snapshot, or `undefined` when the log has no title event.
|
||||
*/
|
||||
async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return foldSessionTitle(loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* List lightweight raw-log event records for one logical session.
|
||||
* @param sessionId - live-preferred session id to read.
|
||||
|
||||
@@ -6,6 +6,7 @@ import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, {
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
@@ -85,6 +86,58 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
|
||||
}
|
||||
|
||||
describe('session-query exact reads', () => {
|
||||
it('reads the latest title from one live-preferred or persisted log without widening listSessions', async () => {
|
||||
const persistedHeader = header('persisted-title', 2)
|
||||
const sharedHeader = header('shared-title', 3)
|
||||
TestPersistence.reset([
|
||||
{
|
||||
meta: persistedHeader,
|
||||
events: [{
|
||||
type: 'session/title',
|
||||
seq: 0,
|
||||
time: 20,
|
||||
data: {
|
||||
title: 'Persisted title',
|
||||
messageSeqs: [4],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
meta: sharedHeader,
|
||||
events: [{
|
||||
type: 'session/title',
|
||||
seq: 0,
|
||||
time: 30,
|
||||
data: {
|
||||
title: 'Stale durable title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
])
|
||||
const ctx = await liveContext()
|
||||
const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } })
|
||||
shared.append('session/title', {
|
||||
title: 'Live title',
|
||||
messageSeqs: [7],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('query-test'),
|
||||
},
|
||||
})
|
||||
await ctx.plugin(TestPersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.readTitle(persistedHeader.id)).resolves.toMatchObject({
|
||||
title: 'Persisted title', eventSeq: 0, updatedAt: 20,
|
||||
})
|
||||
await expect(ctx.sessionQuery.readTitle(shared.id)).resolves.toMatchObject({
|
||||
title: 'Live title', eventSeq: 0,
|
||||
})
|
||||
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
|
||||
})
|
||||
|
||||
it('lists live sessions deterministically and returns detached headers', async () => {
|
||||
const ctx = await liveContext()
|
||||
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
|
||||
12
packages/session-title/README.md
Normal file
12
packages/session-title/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# session-title/ — log-backed session-title capability family
|
||||
|
||||
Durable session-title state, one optional asynchronous provider seam, and two opt-in model-backed implementations. The built-in first-message fallback is part of the service, so every composition can title a session without an auxiliary model call.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-title/`](session-title/README.md) | Log fold, deterministic fallback, provider registry, and refresh API | `ctx.sessionTitle` |
|
||||
| [`session-title-llm/`](session-title-llm/README.md) | Shared route, request logging, prompt, timeout, stream, and validation helper | — |
|
||||
| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | Optional provider using the first eligible human message | registers on `ctx.sessionTitle` |
|
||||
| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | Optional provider using every eligible human message | registers on `ctx.sessionTitle` |
|
||||
|
||||
Only one provider may register at a time. The shared demo spine mounts the fallback service but leaves both model providers outside default composition, so deployments choose auxiliary cost and retitling cadence explicitly.
|
||||
@@ -0,0 +1,26 @@
|
||||
# @deepseek-ai/dsh-session-title-all-messages-llm
|
||||
|
||||
Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-user-messages` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output.
|
||||
|
||||
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If the final framed aggregate prompt exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### All-messages title request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The title model receives the shared title instruction and a JSON array of all eligible human messages through the current revision, in log order with exact seqs. Seeded history is included.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One auxiliary request may follow every new eligible prompt, bounded per request by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may add calls. The main agent request gains zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No main-request invalidation. Auxiliary input grows or changes after each prompt, so provider-specific cache reuse ends at the first changed JSON token.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Input overflow retains the prior title; this provider has no summarization-of-summaries or retention policy for very long sessions.
|
||||
- It treats all eligible human messages equally and offers no weighting, filtering, or manual-title precedence.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-title-all-messages-llm",
|
||||
"description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** All-human-messages model provider for `ctx.sessionTitle`. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
registerSessionTitleLlmProvider,
|
||||
SessionTitleLlmConfigFields,
|
||||
} from '@deepseek-ai/dsh-session-title-llm'
|
||||
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
|
||||
|
||||
export const name = 'session-title-all-messages-llm'
|
||||
export const inject = ['sessionTitle', 'llm', 'sessions']
|
||||
|
||||
/** Required LLM policy; this plugin adds no defaults. */
|
||||
export type Config = SessionTitleLlmConfig
|
||||
/** Loader schema shared with the first-message provider. */
|
||||
/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
|
||||
export const Config: z<Config> = z.object({
|
||||
targetWords: SessionTitleLlmConfigFields.targetWords,
|
||||
targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters,
|
||||
maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes,
|
||||
maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens,
|
||||
timeoutMs: SessionTitleLlmConfigFields.timeoutMs,
|
||||
provider: SessionTitleLlmConfigFields.provider,
|
||||
model: SessionTitleLlmConfigFields.model,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the all-user-messages model provider.
|
||||
* @param ctx - context exposing session-title, LLM, and session services.
|
||||
* @param config - required route, target, byte, token, and timeout policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
registerSessionTitleLlmProvider(ctx, config, name, 'all-user-messages', messages => messages)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-all-messages-llm`.
|
||||
* @module @deepseek-ai/dsh-session-title-all-messages-llm/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-all-messages-llm'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-title-all-messages-llm-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this thin provider delegates request and result validation to the shared
|
||||
* title service and LLM helper and retains no independent mutable state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-messages-llm'
|
||||
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
yield { type: 'text-delta', index: 0, text: 'All messages model title' }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const
|
||||
const LLM_CONFIG = {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 1_000,
|
||||
maxOutputTokens: 32,
|
||||
timeoutMs: 1_000,
|
||||
} as const
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('all-messages LLM title provider', () => {
|
||||
it('includes seeded history and the latest prompt while inheriting the logged request route', async () => {
|
||||
const seeded = new Session(SessionId('seed-source'))
|
||||
seeded.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const inherited = seeded.append('user/message', {
|
||||
content: [{ type: 'text', text: 'inherited prompt' }], source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
seeded.append('session/title', {
|
||||
title: 'Inherited fallback', messageSeqs: [inherited.seq], source: { kind: 'fallback' },
|
||||
})
|
||||
seeded.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
|
||||
const adapter = new RecordingAdapter()
|
||||
ctx.llm.registerAdapter(['current-route'], adapter)
|
||||
await ctx.plugin(providerPlugin, LLM_CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('all-plugin'), {
|
||||
seed: seeded.events,
|
||||
meta: { parentSession: seeded.id, seedLength: seeded.seq },
|
||||
})
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const latest = session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'latest prompt' }], source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await settle()
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'current-route', model: 'current-model' } }, reason: 'resume',
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(adapter.requests[0]).toMatchObject({ provider: 'current-route', model: 'current-model' })
|
||||
const content = adapter.requests[0]?.messages[0]?.content[0]
|
||||
expect(content?.type === 'text' && content.text).toContain('inherited prompt')
|
||||
expect(content?.type === 'text' && content.text).toContain('latest prompt')
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
messageSeqs: [inherited.seq, latest.seq],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../support/invariants" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../session-title" },
|
||||
{ "path": "../session-title-llm" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# @deepseek-ai/dsh-session-title-first-message-llm
|
||||
|
||||
Optional `ctx.sessionTitle` provider that summarizes the first eligible human message through `ctx.llm`. It registers the `first-message` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`.
|
||||
|
||||
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from the current logged main request, or set both to route title generation independently.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### First-message title request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The title model receives the shared title instruction and a JSON array containing only the first eligible human message. Later prompts and inherited fork history do not trigger another automatic call.
|
||||
|
||||
#### Token effect
|
||||
|
||||
At most one automatic auxiliary request is made for a fresh session, bounded by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may make additional calls. The main agent request gains zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No main-request invalidation. The auxiliary request uses the configured or logged route and has provider-specific cache behavior.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The first message alone may cease to represent a long-running session; use the all-messages provider when later prompts should retitle it.
|
||||
- A fork keeps its inherited title and never runs this provider automatically, even when its seeded first message came from the parent.
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-title-first-message-llm",
|
||||
"description": "First-message LLM provider plugin for DeepSeek Harness session titles",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** First-human-message model provider for `ctx.sessionTitle`. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
registerSessionTitleLlmProvider,
|
||||
SessionTitleLlmConfigFields,
|
||||
} from '@deepseek-ai/dsh-session-title-llm'
|
||||
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
|
||||
|
||||
export const name = 'session-title-first-message-llm'
|
||||
export const inject = ['sessionTitle', 'llm', 'sessions']
|
||||
|
||||
/** Required LLM policy; this plugin adds no defaults. */
|
||||
export type Config = SessionTitleLlmConfig
|
||||
/** Loader schema shared with the all-messages provider. */
|
||||
/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
|
||||
export const Config: z<Config> = z.object({
|
||||
targetWords: SessionTitleLlmConfigFields.targetWords,
|
||||
targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters,
|
||||
maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes,
|
||||
maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens,
|
||||
timeoutMs: SessionTitleLlmConfigFields.timeoutMs,
|
||||
provider: SessionTitleLlmConfigFields.provider,
|
||||
model: SessionTitleLlmConfigFields.model,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the first-message model provider.
|
||||
* @param ctx - context exposing session-title, LLM, and session services.
|
||||
* @param config - required route, target, byte, token, and timeout policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
registerSessionTitleLlmProvider(ctx, config, name, 'first-message', (messages) => {
|
||||
const first = messages[0]
|
||||
if (first === undefined) throw new Error('first-message title provider requires one human message')
|
||||
return [first]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-first-message-llm`.
|
||||
* @module @deepseek-ai/dsh-session-title-first-message-llm/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-title-first-message-llm-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this thin provider delegates request and result validation to the shared
|
||||
* title service and LLM helper and retains no independent mutable state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
class LoaderAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
yield { type: 'text-delta', index: 0, text: 'Loader composed title' }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
async function loadComposition(): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-title-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-llm'",
|
||||
"- name: '@deepseek-ai/dsh-session'",
|
||||
"- name: '@deepseek-ai/dsh-session-title'",
|
||||
' config:',
|
||||
' fallbackMaxWords: 5',
|
||||
' fallbackMaxBytes: 40',
|
||||
' maxTitleBytes: 80',
|
||||
"- name: '@deepseek-ai/dsh-session-title-first-message-llm'",
|
||||
' config:',
|
||||
' targetWords: 5',
|
||||
' targetCjkCharacters: 10',
|
||||
' maxInputBytes: 1000',
|
||||
' maxOutputTokens: 32',
|
||||
' timeoutMs: 1000',
|
||||
" provider: 'title-route'",
|
||||
" model: 'title-model'",
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-llm', LlmService],
|
||||
['@deepseek-ai/dsh-session', SessionStore],
|
||||
['@deepseek-ai/dsh-session-title', SessionTitleService],
|
||||
['@deepseek-ai/dsh-session-title-first-message-llm', providerPlugin],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
return context
|
||||
}
|
||||
|
||||
describe('session-title Loader composition', () => {
|
||||
it('loads the service and one model provider with required deployment policy', async () => {
|
||||
const ctx = await loadComposition()
|
||||
const unloaded = [...ctx.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
|
||||
const adapter = new LoaderAdapter()
|
||||
ctx.llm.registerAdapter(['title-route'], adapter)
|
||||
const session = ctx.sessions.create(SessionId('loader-title'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const message = session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'Compose a title through Loader' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main-route', model: 'main-model' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(adapter.requests[0]).toMatchObject({ provider: 'title-route', model: 'title-model' })
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'Loader composed title',
|
||||
messageSeqs: [message.seq],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: 'session-title-first-message-llm',
|
||||
model: { provider: 'title-route', model: 'title-model' },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-message title provider with real DeepSeek API', () => {
|
||||
it('replaces the fallback with a short model title', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' })
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
})
|
||||
await ctx.plugin(FirstMessageTitleProvider, {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 4_096,
|
||||
maxOutputTokens: 64,
|
||||
timeoutMs: 60_000,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('real-title-provider'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const message = session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'Explain why append-only logs make session titles durable.' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const title = await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(title).toMatchObject({
|
||||
messageSeqs: [message.seq],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: 'session-title-first-message-llm',
|
||||
model: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
})
|
||||
expect(title?.title.length).toBeGreaterThan(0)
|
||||
expect(Buffer.byteLength(title?.title ?? '', 'utf8')).toBeLessThanOrEqual(80)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title'
|
||||
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
yield { type: 'text-delta', index: 0, text: 'First-message model title' }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const
|
||||
const LLM_CONFIG = {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 1_000,
|
||||
maxOutputTokens: 32,
|
||||
timeoutMs: 1_000,
|
||||
provider: 'title-route',
|
||||
model: 'title-model',
|
||||
} as const
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('first-message LLM title provider', () => {
|
||||
it('rejects an impossible empty provider request at its own boundary', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
|
||||
let registered: SessionTitleProvider | undefined
|
||||
vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => {
|
||||
registered = provider
|
||||
return async () => undefined
|
||||
})
|
||||
providerPlugin.apply(ctx, LLM_CONFIG)
|
||||
|
||||
await expect(registered!.generate({
|
||||
session: new Session(SessionId('empty-first-provider')),
|
||||
messages: [],
|
||||
signal: new AbortController().signal,
|
||||
})).rejects.toThrow(/requires one human message/)
|
||||
})
|
||||
|
||||
it('always selects only the first eligible human message, including explicit refresh', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
|
||||
const adapter = new RecordingAdapter()
|
||||
ctx.llm.registerAdapter(['title-route'], adapter)
|
||||
await ctx.plugin(providerPlugin, LLM_CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('first-plugin'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const first = session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first input' }], source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await settle()
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main', model: 'main-model' } }, reason: 'initial',
|
||||
})
|
||||
await settle()
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'second input must be ignored' }], source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
for (const options of adapter.requests) {
|
||||
const content = options.messages[0]?.content[0]
|
||||
expect(content?.type === 'text' && content.text).toContain('first input')
|
||||
expect(content?.type === 'text' && content.text).not.toContain('second input must be ignored')
|
||||
}
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({ messageSeqs: [first.seq] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../support/invariants" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../session-title" },
|
||||
{ "path": "../session-title-llm" }
|
||||
]
|
||||
}
|
||||
45
packages/session-title/session-title-llm/README.md
Normal file
45
packages/session-title/session-title-llm/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @deepseek-ai/dsh-session-title-llm
|
||||
|
||||
Shared implementation policy for model-backed session-title providers. It resolves the auxiliary route, frames exact selected human messages as JSON, records the exact dispatchable request, applies a language-aware title instruction, enforces input and output budgets, composes timeout and caller cancellation, assembles the stream, and returns normalized text with exact source seqs and model provenance.
|
||||
|
||||
This package is a library, not a Cordis plugin. The provider plugins call `registerSessionTitleLlmProvider()` with their cadence and message selector; it validates shared config and delegates each revision to `generateSessionTitleWithLlm()`, so registration, route, prompt, cancellation, and validation behavior cannot drift between them.
|
||||
|
||||
## Route and failure contract
|
||||
|
||||
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. The helper measures the final JSON-framed user prompt, including seq fields, wrappers, and JSON escaping, against `maxInputBytes` before logging or dispatch instead of truncating it. Timeout and caller cancellation are rechecked while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. Malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure.
|
||||
|
||||
After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. The append shares the title capability's per-session settlement queue, so a superseding request cannot collide with an earlier fallback, request record, or accepted-title flush. The dispatched envelope is deep-frozen to keep interceptors aligned with that record but deliberately lacks dsh-agent-loop's process-local request identity, so loop-only reconstruction observers do not compare it with the conversation header. A later model failure leaves that request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every field is required except the paired route override; there are no library defaults.
|
||||
|
||||
| Key | Contract |
|
||||
|---|---|
|
||||
| `targetWords` | Positive target word count for non-CJK titles. |
|
||||
| `targetCjkCharacters` | Positive target character count for Chinese, Japanese, or Korean titles. |
|
||||
| `maxInputBytes` | Positive UTF-8 byte ceiling for the final JSON-framed user prompt. |
|
||||
| `maxOutputTokens` | Positive auxiliary generation token cap. |
|
||||
| `timeoutMs` | Positive end-to-end deadline within the runtime timer limit. |
|
||||
| `provider`, `model` | Optional explicit route; both or neither. |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Auxiliary title request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The title model receives a fixed system instruction to return one concise unadorned title in the input language, including the configured word and CJK-character targets. Its one user message contains a JSON array of the exact selected human messages and their seqs.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The auxiliary request consumes tokens according to selected input size and `maxOutputTokens`. It is separate from the main agent request and does not add title text or framing to agent history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No main-request invalidation. Auxiliary cache reuse is provider-specific; the fixed instruction is reusable while the JSON message array changes with each revision.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The helper accepts text output only and rejects tool calls; structured-output adapters and provider-specific prompt variants are not exposed.
|
||||
- It enforces a byte ceiling for the whole framed user prompt rather than clipping individual messages or applying a retention policy.
|
||||
48
packages/session-title/session-title-llm/package.json
Normal file
48
packages/session-title/session-title-llm/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-title-llm",
|
||||
"description": "Shared LLM generation policy for DeepSeek Harness session-title providers",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
298
packages/session-title/session-title-llm/src/index.ts
Normal file
298
packages/session-title/session-title-llm/src/index.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Shared route, framing, timeout, assembly, and validation policy for
|
||||
* model-backed session-title providers.
|
||||
* @module @deepseek-ai/dsh-session-title-llm
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
appendSessionTitleOutOfBand,
|
||||
normalizeSessionTitle,
|
||||
SessionTitleProviderId,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionTitleAutomaticMode,
|
||||
SessionTitleModelProvenance,
|
||||
SessionTitleProviderRequest,
|
||||
SessionTitleProviderResult,
|
||||
SessionTitleUserMessage,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Exact model-visible request recorded before one auxiliary title dispatch. */
|
||||
export interface SessionTitleLlmRequestEventData {
|
||||
/** Registered title-provider identity responsible for the request. */
|
||||
readonly titleProvider: SessionTitleProviderId
|
||||
/** Exact human `user/message` seqs represented in `messages`. */
|
||||
readonly messageSeqs: number[]
|
||||
/** Exact auxiliary LLM route. */
|
||||
readonly route: SessionTitleModelProvenance
|
||||
/** Exact auxiliary system prompt. */
|
||||
readonly system: string
|
||||
/** Exact auxiliary message list. */
|
||||
readonly messages: Message[]
|
||||
/** Exact auxiliary output-token cap. */
|
||||
readonly maxTokens: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Log-only pre-dispatch record of one session-title model request. */
|
||||
'session/title-llm-request': SessionTitleLlmRequestEventData
|
||||
}
|
||||
|
||||
interface OutOfBandSessionEventMap {
|
||||
'session/title-llm-request': true
|
||||
}
|
||||
}
|
||||
|
||||
/** Capability-owned timeout reason code for auxiliary title requests. */
|
||||
export const SESSION_TITLE_TIMEOUT_CODE = 'SESSION_TITLE_TIMEOUT'
|
||||
|
||||
/** Required deployment policy for one model-backed title plugin. */
|
||||
export interface SessionTitleLlmConfig {
|
||||
/** Target word count for non-CJK titles. */
|
||||
readonly targetWords: number
|
||||
/** Target character count for Chinese, Japanese, or Korean titles. */
|
||||
readonly targetCjkCharacters: number
|
||||
/** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
|
||||
readonly maxInputBytes: number
|
||||
/** Auxiliary generation output-token cap. */
|
||||
readonly maxOutputTokens: number
|
||||
/** End-to-end auxiliary request deadline in milliseconds. */
|
||||
readonly timeoutMs: number
|
||||
/** Optional explicit provider route; must be paired with `model`. */
|
||||
readonly provider?: string
|
||||
/** Optional explicit model id; must be paired with `provider`. */
|
||||
readonly model?: string
|
||||
}
|
||||
|
||||
/** Validated immutable model-provider policy. */
|
||||
export interface ResolvedSessionTitleLlmConfig extends SessionTitleLlmConfig {}
|
||||
|
||||
/** Shared Loader field schemas with no library defaults. */
|
||||
export const SessionTitleLlmConfigFields = {
|
||||
targetWords: z.number().step(1).min(1).required(),
|
||||
targetCjkCharacters: z.number().step(1).min(1).required(),
|
||||
maxInputBytes: z.number().step(1).min(1).required(),
|
||||
maxOutputTokens: z.number().step(1).min(1).required(),
|
||||
timeoutMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS).required(),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}
|
||||
|
||||
/** Shared Loader schema with no library defaults. */
|
||||
export const SessionTitleLlmConfigSchema: z<SessionTitleLlmConfig> = z.object(SessionTitleLlmConfigFields)
|
||||
|
||||
/** Complete configuration key set for direct construction validation. */
|
||||
const CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'targetWords',
|
||||
'targetCjkCharacters',
|
||||
'maxInputBytes',
|
||||
'maxOutputTokens',
|
||||
'timeoutMs',
|
||||
'provider',
|
||||
'model',
|
||||
])
|
||||
|
||||
/** Validate one positive integer limit. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`session-title-llm: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and detach required model-provider configuration.
|
||||
* @param config - untrusted plugin configuration.
|
||||
* @returns immutable policy with optional route absence preserved.
|
||||
*/
|
||||
export function resolveSessionTitleLlmConfig(
|
||||
config: SessionTitleLlmConfig,
|
||||
): ResolvedSessionTitleLlmConfig {
|
||||
const candidate: unknown = config
|
||||
if (candidate === null || typeof candidate !== 'object') {
|
||||
throw new Error('session-title-llm: configuration is required')
|
||||
}
|
||||
const value = candidate as SessionTitleLlmConfig
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!CONFIG_KEYS.has(key)) throw new Error(`session-title-llm: unknown config key "${key}"`)
|
||||
}
|
||||
assertPositiveInteger('targetWords', value.targetWords)
|
||||
assertPositiveInteger('targetCjkCharacters', value.targetCjkCharacters)
|
||||
assertPositiveInteger('maxInputBytes', value.maxInputBytes)
|
||||
assertPositiveInteger('maxOutputTokens', value.maxOutputTokens)
|
||||
assertPositiveInteger('timeoutMs', value.timeoutMs)
|
||||
if (value.timeoutMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`session-title-llm: timeoutMs must not exceed ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
const hasProvider = value.provider !== undefined
|
||||
const hasModel = value.model !== undefined
|
||||
if (hasProvider !== hasModel) {
|
||||
throw new Error('session-title-llm: provider and model must be supplied together')
|
||||
}
|
||||
if (hasProvider
|
||||
&& (typeof value.provider !== 'string' || value.provider.length === 0
|
||||
|| typeof value.model !== 'string' || value.model.length === 0)) {
|
||||
throw new Error('session-title-llm: provider and model overrides must be non-empty strings')
|
||||
}
|
||||
return deepFreeze({ ...value })
|
||||
}
|
||||
|
||||
/** Select the provider-owned message subset from one fixed service revision. */
|
||||
export type SessionTitleLlmMessageSelector = (
|
||||
messages: readonly SessionTitleUserMessage[],
|
||||
) => readonly SessionTitleUserMessage[]
|
||||
|
||||
/**
|
||||
* Register one model-backed provider through the shared configuration and call policy.
|
||||
* @param ctx - context exposing the title and LLM services.
|
||||
* @param config - untrusted required deployment policy.
|
||||
* @param id - stable plugin identity recorded in title provenance.
|
||||
* @param automatic - provider-owned automatic generation cadence.
|
||||
* @param selectMessages - exact source-message selection for one revision.
|
||||
*/
|
||||
export function registerSessionTitleLlmProvider(
|
||||
ctx: Context,
|
||||
config: SessionTitleLlmConfig,
|
||||
id: string,
|
||||
automatic: SessionTitleAutomaticMode,
|
||||
selectMessages: SessionTitleLlmMessageSelector,
|
||||
): void {
|
||||
const resolved = resolveSessionTitleLlmConfig(config)
|
||||
const titleProvider = SessionTitleProviderId(id)
|
||||
ctx.sessionTitle.register({
|
||||
id: titleProvider,
|
||||
automatic,
|
||||
async generate(request) {
|
||||
return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages), titleProvider)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve the explicit pair or the exact route captured from `request/header`. */
|
||||
function resolveRoute(
|
||||
config: ResolvedSessionTitleLlmConfig,
|
||||
request: SessionTitleProviderRequest,
|
||||
): SessionTitleModelProvenance {
|
||||
if (config.provider !== undefined && config.model !== undefined) {
|
||||
return { provider: config.provider, model: config.model }
|
||||
}
|
||||
if (request.route === undefined) {
|
||||
throw new Error('session-title-llm: no logged request route is available; configure provider and model together')
|
||||
}
|
||||
return request.route
|
||||
}
|
||||
|
||||
/** Stable language-aware system instruction shared by both provider plugins. */
|
||||
function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
|
||||
return [
|
||||
'Create a concise title for an AI coding-assistant session from the supplied human messages.',
|
||||
'Return only the title on one line, with no quotes, prefix, explanation, Markdown, or terminal control codes.',
|
||||
'Use the language of the messages.',
|
||||
`Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Frame exact messages as JSON so user text cannot break structural delimiters. */
|
||||
function frameMessages(messages: readonly SessionTitleUserMessage[]): string {
|
||||
return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`
|
||||
}
|
||||
|
||||
/** Translate terminal finish reasons into an auxiliary-call failure. */
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'stop':
|
||||
return undefined
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const error = new Error(finish.failure.message) as Error & { code?: string }
|
||||
error.code = finish.failure.code
|
||||
return error
|
||||
}
|
||||
case 'max-tokens':
|
||||
return new Error('session-title-llm: title output reached maxOutputTokens')
|
||||
case 'tool-calls':
|
||||
return new Error('session-title-llm: title model unexpectedly requested a tool')
|
||||
default:
|
||||
return new Error(`session-title-llm: unsupported finish reason "${String((finish as { kind?: unknown }).kind)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one title through the shared auxiliary LLM call.
|
||||
* @param ctx - context exposing the registered LLM service.
|
||||
* @param config - validated model-provider policy.
|
||||
* @param request - service-owned session, route, message snapshot, and cancellation.
|
||||
* @param selectedMessages - exact provider-selected subset to frame and attribute.
|
||||
* @param titleProvider - registered title-provider identity recorded with the request.
|
||||
* @returns normalized non-empty title, exact source seqs, and used model route.
|
||||
*/
|
||||
export async function generateSessionTitleWithLlm(
|
||||
ctx: Context,
|
||||
config: ResolvedSessionTitleLlmConfig,
|
||||
request: SessionTitleProviderRequest,
|
||||
selectedMessages: readonly SessionTitleUserMessage[],
|
||||
titleProvider: SessionTitleProviderId,
|
||||
): Promise<SessionTitleProviderResult> {
|
||||
request.signal.throwIfAborted()
|
||||
if (selectedMessages.length === 0) {
|
||||
throw new Error('session-title-llm: at least one source message is required')
|
||||
}
|
||||
const framedInput = frameMessages(selectedMessages)
|
||||
const inputBytes = Buffer.byteLength(framedInput, 'utf8')
|
||||
if (inputBytes > config.maxInputBytes) {
|
||||
throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
|
||||
}
|
||||
const route = resolveRoute(config, request)
|
||||
const messages: Message[] = [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: framedInput }],
|
||||
}]
|
||||
const system = systemPrompt(config)
|
||||
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
|
||||
const options: GenerateOptions = deepFreeze({
|
||||
provider: route.provider,
|
||||
model: route.model,
|
||||
messages,
|
||||
system,
|
||||
maxTokens: config.maxOutputTokens,
|
||||
sessionId: request.session.id,
|
||||
signal: callDeadline.signal,
|
||||
})
|
||||
await appendSessionTitleOutOfBand(ctx, request.session, 'session/title-llm-request', {
|
||||
titleProvider,
|
||||
messageSeqs: selectedMessages.map(message => message.seq),
|
||||
route,
|
||||
system,
|
||||
messages,
|
||||
maxTokens: config.maxOutputTokens,
|
||||
}, callDeadline.signal)
|
||||
callDeadline.signal.throwIfAborted()
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of ctx.llm.stream(options)) {
|
||||
callDeadline.signal.throwIfAborted()
|
||||
assembler.push(chunk)
|
||||
}
|
||||
callDeadline.signal.throwIfAborted()
|
||||
const terminalError = finishError(assembler.finish)
|
||||
if (terminalError !== undefined) throw terminalError
|
||||
const blocks = assembler.message().content
|
||||
if (blocks.some(block => block.type === 'tool-call')) {
|
||||
throw new Error('session-title-llm: title output must contain text only')
|
||||
}
|
||||
const text = blocks
|
||||
.filter((block): block is Extract<(typeof blocks)[number], { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join(' ')
|
||||
const title = normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER)
|
||||
if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
|
||||
return {
|
||||
title,
|
||||
messageSeqs: selectedMessages.map(message => message.seq),
|
||||
model: route,
|
||||
}
|
||||
}
|
||||
30
packages/session-title/session-title-llm/src/invariant.ts
Normal file
30
packages/session-title/session-title-llm/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-llm`.
|
||||
* @module @deepseek-ai/dsh-session-title-llm/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-llm'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-title-llm-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this stateless helper validates and freezes each auxiliary request before
|
||||
* dispatch; deadline, stream, and provenance relationships are checked synchronously and by tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
365
packages/session-title/session-title-llm/tests/llm.spec.ts
Normal file
365
packages/session-title/session-title-llm/tests/llm.spec.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LlmService, { CallId, isAgentLoopRequest, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
generateSessionTitleWithLlm,
|
||||
resolveSessionTitleLlmConfig,
|
||||
SESSION_TITLE_TIMEOUT_CODE,
|
||||
} from '@deepseek-ai/dsh-session-title-llm'
|
||||
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
|
||||
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(
|
||||
private readonly script: readonly StreamChunk[],
|
||||
private readonly onDispatch?: () => void,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.onDispatch?.()
|
||||
this.requests.push(options)
|
||||
yield * this.script
|
||||
}
|
||||
}
|
||||
|
||||
class CooperativeAdapter extends LlmAdapter {
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const signal = options.signal
|
||||
if (signal === undefined) throw new Error('expected title request signal')
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
const rejectAbort = (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation
|
||||
reject(signal.reason)
|
||||
}
|
||||
if (signal.aborted) {
|
||||
rejectAbort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', rejectAbort, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class DelayedSuccessAdapter extends LlmAdapter {
|
||||
constructor(private readonly delayMs: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
override async * stream(): AsyncIterable<StreamChunk> {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, this.delayMs))
|
||||
yield * SCRIPT
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: ' 五个字标题 ' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
const CONFIG = {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 1_000,
|
||||
maxOutputTokens: 32,
|
||||
timeoutMs: 1_000,
|
||||
} as const
|
||||
|
||||
const TITLE_PROVIDER = SessionTitleProviderId('test-title-provider')
|
||||
let nextSession = 0
|
||||
|
||||
function request(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
|
||||
const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const first = session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const second = session.append('user/message', {
|
||||
content: [{ type: 'text', text: '第二个问题' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return {
|
||||
session,
|
||||
messages: [
|
||||
{ seq: first.seq, text: 'first prompt' },
|
||||
{ seq: second.seq, text: '第二个问题' },
|
||||
],
|
||||
route: { provider: 'current-route', model: 'current-model' },
|
||||
signal,
|
||||
}
|
||||
}
|
||||
|
||||
function requestWithoutRoute(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
|
||||
const routed = request(ctx, signal)
|
||||
return { session: routed.session, messages: routed.messages, signal }
|
||||
}
|
||||
|
||||
async function withScript(script: readonly StreamChunk[]): Promise<{
|
||||
ctx: Context
|
||||
adapter: RecordingAdapter
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new RecordingAdapter(script)
|
||||
ctx.llm.registerAdapter(['current-route'], adapter)
|
||||
return { ctx, adapter }
|
||||
}
|
||||
|
||||
describe('generateSessionTitleWithLlm', () => {
|
||||
it('uses the exact logged route, language targets, full framed input, and output token cap', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
const providerRequest = request(ctx)
|
||||
let requestWasLoggedAtDispatch = false
|
||||
const adapter = new RecordingAdapter(SCRIPT, () => {
|
||||
requestWasLoggedAtDispatch = providerRequest.session.events
|
||||
.some(event => event.type === 'session/title-llm-request')
|
||||
})
|
||||
ctx.llm.registerAdapter(['current-route'], adapter)
|
||||
|
||||
const result = await generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
title: '五个字标题',
|
||||
messageSeqs: providerRequest.messages.map(message => message.seq),
|
||||
model: { provider: 'current-route', model: 'current-model' },
|
||||
})
|
||||
expect(requestWasLoggedAtDispatch).toBe(true)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const options = adapter.requests[0]!
|
||||
expect(Object.isFrozen(options)).toBe(true)
|
||||
expect(Object.isFrozen(options.messages)).toBe(true)
|
||||
expect(isAgentLoopRequest(options)).toBe(false)
|
||||
expect(options).toMatchObject({
|
||||
provider: 'current-route',
|
||||
model: 'current-model',
|
||||
maxTokens: 32,
|
||||
sessionId: providerRequest.session.id,
|
||||
})
|
||||
expect(options.system).toContain('5 words')
|
||||
expect(options.system).toContain('10 CJK characters')
|
||||
const prompt = options.messages[0]?.content[0]
|
||||
expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
|
||||
expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
|
||||
expect(providerRequest.session.events.findLast(event => event.type === 'session/title-llm-request')?.data)
|
||||
.toEqual({
|
||||
titleProvider: TITLE_PROVIDER,
|
||||
messageSeqs: providerRequest.messages.map(message => message.seq),
|
||||
route: { provider: 'current-route', model: 'current-model' },
|
||||
system: options.system,
|
||||
messages: options.messages,
|
||||
maxTokens: 32,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new RecordingAdapter(SCRIPT)
|
||||
ctx.llm.registerAdapter(['explicit-route'], adapter)
|
||||
const oversized = request(ctx)
|
||||
const [selected] = oversized.messages
|
||||
if (selected === undefined) throw new Error('expected one selected message')
|
||||
const rawInputBytes = Buffer.byteLength(selected.text, 'utf8')
|
||||
const config = resolveSessionTitleLlmConfig({
|
||||
...CONFIG,
|
||||
provider: 'explicit-route',
|
||||
model: 'explicit-model',
|
||||
maxInputBytes: rawInputBytes,
|
||||
})
|
||||
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER))
|
||||
.rejects.toThrow(/input.*bytes.*maxInputBytes/i)
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
|
||||
|
||||
const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 })
|
||||
const within = request(ctx)
|
||||
await generateSessionTitleWithLlm(ctx, withinLimit, within, [within.messages[0]!], TITLE_PROVIDER)
|
||||
expect(adapter.requests[0]).toMatchObject({
|
||||
provider: 'explicit-route',
|
||||
model: 'explicit-model',
|
||||
})
|
||||
})
|
||||
|
||||
it('requires every deployment limit and a complete optional route pair', () => {
|
||||
expect(() => resolveSessionTitleLlmConfig(undefined as never)).toThrow(/configuration is required/)
|
||||
expect(() => resolveSessionTitleLlmConfig(null as never)).toThrow(/configuration is required/)
|
||||
expect(() => resolveSessionTitleLlmConfig('invalid' as never)).toThrow(/configuration is required/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, extra: true } as SessionTitleLlmConfig))
|
||||
.toThrow(/unknown config key "extra"/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 0 }))
|
||||
.toThrow(/targetWords.*positive integer/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 1.5 }))
|
||||
.toThrow(/targetWords.*positive integer/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'only-provider' }))
|
||||
.toThrow(/provider and model must be supplied together/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, model: 'only-model' }))
|
||||
.toThrow(/provider and model must be supplied together/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: '', model: 'model' }))
|
||||
.toThrow(/overrides must be non-empty strings/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: '' }))
|
||||
.toThrow(/overrides must be non-empty strings/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 1, model: 'model' } as never))
|
||||
.toThrow(/overrides must be non-empty strings/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: 1 } as never))
|
||||
.toThrow(/overrides must be non-empty strings/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.toThrow(/timeoutMs must not exceed/)
|
||||
expect(() => resolveSessionTitleLlmConfig(CONFIG)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an absent route, empty selection, and pre-aborted caller before model dispatch', async () => {
|
||||
const { ctx, adapter } = await withScript(SCRIPT)
|
||||
const config = resolveSessionTitleLlmConfig(CONFIG)
|
||||
const unrouted = requestWithoutRoute(ctx)
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, unrouted, unrouted.messages, TITLE_PROVIDER))
|
||||
.rejects.toThrow(/no logged request route/)
|
||||
const empty = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, empty, [], TITLE_PROVIDER))
|
||||
.rejects.toThrow(/at least one source message/)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('caller stopped'))
|
||||
const aborted = request(ctx, controller.signal)
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, aborted, aborted.messages, TITLE_PROVIDER))
|
||||
.rejects.toThrow('caller stopped')
|
||||
expect(adapter.requests).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ kind: 'error', failure: { message: 'provider failed', code: 'SERVER' } }, 'provider failed', 'SERVER'],
|
||||
[{ kind: 'aborted', failure: { message: 'provider aborted', code: 'ABORTED' } }, 'provider aborted', 'ABORTED'],
|
||||
] satisfies Array<[FinishReason, string, string]>)('preserves %s terminal failure details', async (reason, message, code) => {
|
||||
const { ctx } = await withScript([{ type: 'finish', reason }])
|
||||
const providerRequest = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toMatchObject({ message, code })
|
||||
expect(providerRequest.session.events.some(event => event.type === 'session/title-llm-request')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ kind: 'max-tokens' }, /reached maxOutputTokens/],
|
||||
[{ kind: 'tool-calls' }, /unexpectedly requested a tool/],
|
||||
[{ kind: 'future-finish' } as never, /unsupported finish reason "future-finish"/],
|
||||
] satisfies Array<[FinishReason, RegExp]>)('rejects the terminal finish reason %s', async (reason, error) => {
|
||||
const { ctx } = await withScript([{ type: 'finish', reason }])
|
||||
const providerRequest = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toThrow(error)
|
||||
})
|
||||
|
||||
it('rejects tool-call blocks and a successful response with no text', async () => {
|
||||
const toolScript: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: CallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
const tool = await withScript(toolScript)
|
||||
const toolRequest = request(tool.ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
tool.ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
toolRequest,
|
||||
toolRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toThrow(/output must contain text only/)
|
||||
|
||||
const reasoning = await withScript([
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
{ type: 'reasoning-delta', index: 0, text: 'no final title' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const reasoningRequest = request(reasoning.ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
reasoning.ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
reasoningRequest,
|
||||
reasoningRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toThrow(/produced no text/)
|
||||
})
|
||||
|
||||
it('aborts a cooperative model stream at the configured deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
|
||||
const providerRequest = request(ctx)
|
||||
const pending = generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
code: SESSION_TITLE_TIMEOUT_CODE,
|
||||
timeoutMs: 10,
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a successful stream that completes after the configured deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['current-route'], new DelayedSuccessAdapter(20))
|
||||
const providerRequest = request(ctx)
|
||||
const pending = generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
code: SESSION_TITLE_TIMEOUT_CODE,
|
||||
timeoutMs: 10,
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
17
packages/session-title/session-title-llm/tsconfig.json
Normal file
17
packages/session-title/session-title-llm/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../support/invariants" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../util/timeout" },
|
||||
{ "path": "../session-title" }
|
||||
]
|
||||
}
|
||||
52
packages/session-title/session-title/README.md
Normal file
52
packages/session-title/session-title/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# @deepseek-ai/dsh-session-title
|
||||
|
||||
Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp.
|
||||
|
||||
Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input.
|
||||
|
||||
## Service: `SessionTitleService` (ctx key: `sessionTitle`)
|
||||
|
||||
- `get(session)` folds the latest accepted title from a live or replayed log.
|
||||
- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back a fallback append already entering durability.
|
||||
- `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
|
||||
|
||||
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits, while overlapping automatic and explicit fallback requests share one session-local in-flight append. Service and bundled model-provider records use `appendSessionTitleOutOfBand()` to share a per-session settlement queue, so a replacement request record waits for an earlier title write without serializing the superseded model call itself. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
|
||||
|
||||
Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
|
||||
|
||||
## Configuration
|
||||
|
||||
All limits are required; the library supplies no defaults.
|
||||
|
||||
| Key | Contract |
|
||||
|---|---|
|
||||
| `fallbackMaxWords` | Positive maximum whitespace-delimited words in the deterministic fallback. |
|
||||
| `fallbackMaxBytes` | Positive maximum UTF-8 bytes in the fallback; must not exceed `maxTitleBytes`. |
|
||||
| `maxTitleBytes` | Positive maximum UTF-8 bytes accepted from any source. |
|
||||
|
||||
## Provider contract
|
||||
|
||||
A provider supplies a branded stable id, automatic mode (`first-message` or `all-user-messages`), and `generate(request)`. The request carries the live session, all eligible messages through one fixed revision, the current logged main-request route when available, and cancellation. The result identifies a non-empty title, unique ordered source-message seqs from that request, and optional model provenance. The service normalizes and validates the result before it becomes durable.
|
||||
|
||||
See the [session-title data structures](../../../docs/core-data-structures/session-title.md) and [implemented decision](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Session title state
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. `session/title` is log-only and never enters the session surface, `deriveMessages()`, system prompt, tool schemas, or request prefix.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The fallback and accepted provider revisions add zero tokens to the main agent request. An optional provider's separate auxiliary request is documented by that provider package.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None for the main request; title events do not change its reconstructed content or cache key.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Manual rename, title deletion, generated-versus-user precedence, search, and list indexing are outside this service.
|
||||
- The provider registry deliberately accepts at most one implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence.
|
||||
48
packages/session-title/session-title/package.json
Normal file
48
packages/session-title/session-title/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-title",
|
||||
"description": "Log-backed session title service and provider registry for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
727
packages/session-title/session-title/src/index.ts
Normal file
727
packages/session-title/session-title/src/index.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* Log-backed session title service, deterministic fallback, and provider seam.
|
||||
* @module @deepseek-ai/dsh-session-title
|
||||
*/
|
||||
|
||||
import { Context, FiberState, Service, type Fiber } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
OutOfBandSessionEventType,
|
||||
Session,
|
||||
SessionEvent,
|
||||
SessionEventMap,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
|
||||
|
||||
export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
|
||||
|
||||
/** Identifies one session-title provider registration. */
|
||||
export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
|
||||
|
||||
/**
|
||||
* Brand a raw provider id.
|
||||
* @param id - stable non-empty provider identifier supplied by a plugin.
|
||||
* @returns the same string with the session-title provider brand.
|
||||
*/
|
||||
export function SessionTitleProviderId(id: string): SessionTitleProviderId {
|
||||
return id as SessionTitleProviderId
|
||||
}
|
||||
|
||||
/** Exact auxiliary model route that produced a title. */
|
||||
export interface SessionTitleModelProvenance {
|
||||
/** Registered LLM provider route. */
|
||||
readonly provider: string
|
||||
/** Provider model id. */
|
||||
readonly model: string
|
||||
}
|
||||
|
||||
/** Durable ownership record for an accepted session title. */
|
||||
export type SessionTitleSource =
|
||||
| { readonly kind: 'fallback' }
|
||||
| {
|
||||
readonly kind: 'provider'
|
||||
readonly provider: SessionTitleProviderId
|
||||
readonly model?: SessionTitleModelProvenance
|
||||
}
|
||||
|
||||
/** Payload of the log-only `session/title` event. */
|
||||
export interface SessionTitleEventData {
|
||||
/** Normalized non-empty title text. */
|
||||
readonly title: string
|
||||
/** Exact human `user/message` seqs used to derive this title. */
|
||||
readonly messageSeqs: number[]
|
||||
/** Built-in fallback or registered-provider provenance. */
|
||||
readonly source: SessionTitleSource
|
||||
}
|
||||
|
||||
/** Latest folded title plus the title event's durable envelope facts. */
|
||||
export interface SessionTitleSnapshot extends SessionTitleEventData {
|
||||
/** Seq of the latest `session/title` event. */
|
||||
readonly eventSeq: number
|
||||
/** Timestamp of the latest `session/title` event. */
|
||||
readonly updatedAt: number
|
||||
}
|
||||
|
||||
/** Required deterministic fallback and accepted-title limits. */
|
||||
export interface Config {
|
||||
/** Maximum whitespace-delimited words in the built-in fallback. */
|
||||
readonly fallbackMaxWords: number
|
||||
/** Maximum UTF-8 bytes in the built-in fallback. */
|
||||
readonly fallbackMaxBytes: number
|
||||
/** Maximum UTF-8 bytes in any accepted title. */
|
||||
readonly maxTitleBytes: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionTitle: SessionTitleService
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface TurnTriggerMap {
|
||||
/** Zero-step turn opened only to durably append a late title update. */
|
||||
'session-title': { kind: 'session-title' }
|
||||
}
|
||||
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Latest-wins session title snapshot. Log-only: it never enters the model
|
||||
* surface or derived history.
|
||||
*/
|
||||
'session/title': SessionTitleEventData
|
||||
}
|
||||
|
||||
interface OutOfBandSessionEventMap {
|
||||
'session/title': true
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-session settlement tails for title-capability out-of-band writes. */
|
||||
const SESSION_TITLE_WRITE_TAILS = new WeakMap<Session, Promise<void>>()
|
||||
|
||||
/** Convert either write outcome into a fulfilled queue tail. */
|
||||
function settleSessionTitleWrite(): void {}
|
||||
|
||||
/**
|
||||
* Serialize one title-capability out-of-band event with its session peers.
|
||||
* Cancellation is checked when the write reaches the head of the queue; once
|
||||
* the core append starts, its durability contract runs to completion.
|
||||
* @param ctx - context exposing the live session store.
|
||||
* @param session - exact live session that owns the title-capability event.
|
||||
* @param type - plugin-declared log-only title event type.
|
||||
* @param data - typed JSON payload for the event.
|
||||
* @param signal - service or provider lifetime checked before publication starts.
|
||||
* @returns the durably accepted event.
|
||||
*/
|
||||
export async function appendSessionTitleOutOfBand<T extends OutOfBandSessionEventType>(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
signal: AbortSignal,
|
||||
): Promise<SessionEvent<T>> {
|
||||
const predecessor = SESSION_TITLE_WRITE_TAILS.get(session)
|
||||
const run = Promise.resolve(predecessor).then(() => {
|
||||
signal.throwIfAborted()
|
||||
return ctx.sessions.appendOutOfBand(session, type, data, { kind: 'session-title' })
|
||||
})
|
||||
const tail = run.then(settleSessionTitleWrite, settleSessionTitleWrite)
|
||||
SESSION_TITLE_WRITE_TAILS.set(session, tail)
|
||||
try {
|
||||
return await run
|
||||
} finally {
|
||||
if (SESSION_TITLE_WRITE_TAILS.get(session) === tail) {
|
||||
SESSION_TITLE_WRITE_TAILS.delete(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One eligible human text message exposed to title providers. */
|
||||
export interface SessionTitleUserMessage {
|
||||
/** Source `user/message` event seq. */
|
||||
readonly seq: number
|
||||
/** Exact concatenated text-block content. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/** Automatic generation cadence owned by a registered provider. */
|
||||
export type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages'
|
||||
|
||||
/** Immutable input supplied to one title-provider call. */
|
||||
export interface SessionTitleProviderRequest {
|
||||
/** Live session being titled. */
|
||||
readonly session: Session
|
||||
/** All eligible human messages through this generation revision. */
|
||||
readonly messages: readonly SessionTitleUserMessage[]
|
||||
/** Exact current logged main-request route, when one has been recorded. */
|
||||
readonly route?: SessionTitleModelProvenance
|
||||
/** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/** Provider output before service-owned normalization and durable acceptance. */
|
||||
export interface SessionTitleProviderResult {
|
||||
/** Proposed title text. */
|
||||
readonly title: string
|
||||
/** Exact seqs from `request.messages` used by this result. */
|
||||
readonly messageSeqs: readonly number[]
|
||||
/** Auxiliary LLM route, when generation used a model. */
|
||||
readonly model?: SessionTitleModelProvenance
|
||||
}
|
||||
|
||||
/** One optional asynchronous title implementation registered with the service. */
|
||||
export interface SessionTitleProvider {
|
||||
/** Stable provider identity recorded in title provenance. */
|
||||
readonly id: SessionTitleProviderId
|
||||
/** When new human prompts start automatic generation. */
|
||||
readonly automatic: SessionTitleAutomaticMode
|
||||
/**
|
||||
* Produce one title revision.
|
||||
* @param request - message snapshot, current route, session, and cancellation.
|
||||
* @returns proposed title plus exact input seqs and optional model provenance.
|
||||
*/
|
||||
generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect human text-bearing user messages in log order.
|
||||
* @param events - session log or persisted replay.
|
||||
* @param throughSeq - optional inclusive event boundary.
|
||||
* @returns eligible messages with exact source seqs.
|
||||
*/
|
||||
export function collectSessionTitleMessages(
|
||||
events: readonly SessionEvent[],
|
||||
throughSeq?: number,
|
||||
): SessionTitleUserMessage[] {
|
||||
const messages: SessionTitleUserMessage[] = []
|
||||
for (const event of events) {
|
||||
if (throughSeq !== undefined && event.seq > throughSeq) break
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
|
||||
const text = event.data.content
|
||||
.filter((block): block is Extract<(typeof event.data.content)[number], { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n')
|
||||
if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue
|
||||
messages.push({ seq: event.seq, text })
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the latest logged title without consulting mutable metadata.
|
||||
* @param events - live or persisted session log.
|
||||
* @returns the latest immutable title snapshot, or `undefined`.
|
||||
*/
|
||||
export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
|
||||
const event = events.findLast(item => item.type === 'session/title')
|
||||
if (event === undefined) return undefined
|
||||
return deepFreeze({
|
||||
title: event.data.title,
|
||||
messageSeqs: [...event.data.messageSeqs],
|
||||
source: event.data.source.kind === 'fallback'
|
||||
? { kind: 'fallback' }
|
||||
: {
|
||||
kind: 'provider',
|
||||
provider: event.data.source.provider,
|
||||
...(event.data.source.model === undefined
|
||||
? {}
|
||||
: { model: { ...event.data.source.model } }),
|
||||
},
|
||||
eventSeq: event.seq,
|
||||
updatedAt: event.time,
|
||||
})
|
||||
}
|
||||
|
||||
/** Service-owned resolved limits. */
|
||||
interface ResolvedConfig {
|
||||
readonly fallbackMaxWords: number
|
||||
readonly fallbackMaxBytes: number
|
||||
readonly maxTitleBytes: number
|
||||
}
|
||||
|
||||
/** One exact provider registration generation. */
|
||||
interface ProviderRegistration {
|
||||
readonly provider: SessionTitleProvider
|
||||
readonly active: Set<Promise<unknown>>
|
||||
closing: boolean
|
||||
}
|
||||
|
||||
/** Automatic work waiting for the matching main-request header. */
|
||||
interface PendingAutomaticWork {
|
||||
readonly registration: ProviderRegistration
|
||||
readonly revision: number
|
||||
readonly throughSeq: number
|
||||
}
|
||||
|
||||
/** Provider call currently allowed to commit for one session. */
|
||||
interface ActiveProviderWork extends PendingAutomaticWork {
|
||||
readonly controller: AbortController
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/** Mutable concurrency state scoped to one live session. */
|
||||
interface SessionTitleWorkState {
|
||||
revision: number
|
||||
fallback?: Promise<SessionTitleSnapshot | undefined>
|
||||
pending?: PendingAutomaticWork
|
||||
active?: ActiveProviderWork
|
||||
}
|
||||
|
||||
/** Validate one positive integer configuration field. */
|
||||
function assertPositiveInteger(name: keyof Config, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`session-title: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Log-backed title fold plus asynchronous fallback generation. */
|
||||
export class SessionTitleService extends Service {
|
||||
static inject = ['sessions']
|
||||
static Config: z<Config> = z.object({
|
||||
fallbackMaxWords: z.number().step(1).min(1).required(),
|
||||
fallbackMaxBytes: z.number().step(1).min(1).required(),
|
||||
maxTitleBytes: z.number().step(1).min(1).required(),
|
||||
})
|
||||
|
||||
private readonly config: ResolvedConfig
|
||||
private readonly ownerFiber: Fiber
|
||||
private registration: ProviderRegistration | undefined
|
||||
private readonly work = new Map<Session, SessionTitleWorkState>()
|
||||
private readonly lifetime = new AbortController()
|
||||
private readonly inFlight = new Set<Promise<unknown>>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'sessionTitle')
|
||||
this.ownerFiber = ctx.fiber
|
||||
const candidate: unknown = config
|
||||
if (candidate === null || typeof candidate !== 'object') {
|
||||
throw new Error('session-title: configuration is required')
|
||||
}
|
||||
const value = candidate as Config
|
||||
assertPositiveInteger('fallbackMaxWords', value.fallbackMaxWords)
|
||||
assertPositiveInteger('fallbackMaxBytes', value.fallbackMaxBytes)
|
||||
assertPositiveInteger('maxTitleBytes', value.maxTitleBytes)
|
||||
if (value.fallbackMaxBytes > value.maxTitleBytes) {
|
||||
throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes')
|
||||
}
|
||||
this.config = deepFreeze({ ...value })
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
this.lifetime.abort(new Error('session-title service disposed'))
|
||||
if (this.registration !== undefined) this.registration.closing = true
|
||||
this.registration = undefined
|
||||
for (const state of this.work.values()) {
|
||||
delete state.pending
|
||||
state.active?.controller.abort(new Error('session-title service disposed'))
|
||||
}
|
||||
await this.drain(this.inFlight)
|
||||
this.work.clear()
|
||||
}, 'sessionTitle lifecycle')
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
this.onUserMessage(session, event)
|
||||
break
|
||||
case 'request/header':
|
||||
this.onRequestHeader(session, event)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
this.onMainRequest(options)
|
||||
return next()
|
||||
}, { global: true, prepend: true })
|
||||
ctx.on('session/disposed', (session) => {
|
||||
const state = this.work.get(session)
|
||||
if (state === undefined) return
|
||||
state.active?.controller.abort(new Error('session disposed during title generation'))
|
||||
this.work.delete(session)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest folded title from one live or replayed session.
|
||||
* @param session - session whose log is the title source of truth.
|
||||
* @returns latest title snapshot, or `undefined` before eligible input.
|
||||
*/
|
||||
get(session: Session): SessionTitleSnapshot | undefined {
|
||||
return foldSessionTitle(session.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly retry the registered provider, or materialize the built-in
|
||||
* fallback when no provider is registered.
|
||||
* @param session - exact live session to refresh.
|
||||
* @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection.
|
||||
* @returns latest accepted title, or `undefined` when no eligible text exists.
|
||||
*/
|
||||
async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined> {
|
||||
signal?.throwIfAborted()
|
||||
this.assertServiceActive()
|
||||
if (this.ctx.sessions.get(session.id) !== session) {
|
||||
throw new Error(`session "${session.id}" is not live in this store`)
|
||||
}
|
||||
const registration = this.registration
|
||||
const messages = collectSessionTitleMessages(session.events)
|
||||
const latest = messages.at(-1)
|
||||
if (registration === undefined || registration.closing || latest === undefined) {
|
||||
const fallback = await this.ensureFallback(session)
|
||||
signal?.throwIfAborted()
|
||||
return fallback
|
||||
}
|
||||
const state = this.stateFor(session)
|
||||
const revision = this.supersede(state, 'explicit title refresh superseded older generation')
|
||||
const work = this.activate({
|
||||
registration,
|
||||
revision,
|
||||
throughSeq: latest.seq,
|
||||
}, state, signal)
|
||||
const config = session.requestHeader()?.config
|
||||
const route = config === undefined ? undefined : { provider: config.provider, model: config.model }
|
||||
return this.startProvider(session, work, route)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the sole optional title provider. Disposal aborts its pending and
|
||||
* active work before another provider may register.
|
||||
* @param provider - provider identity, cadence, and generation function.
|
||||
* @returns exact Cordis effect disposer, which settles after active calls quiesce.
|
||||
*/
|
||||
register(provider: SessionTitleProvider): () => Promise<void> {
|
||||
this.validateProvider(provider)
|
||||
if (this.registration !== undefined) {
|
||||
throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`)
|
||||
}
|
||||
const registration: ProviderRegistration = {
|
||||
provider,
|
||||
active: new Set(),
|
||||
closing: false,
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SessionTitleService) {
|
||||
this.registration = registration
|
||||
yield async () => {
|
||||
registration.closing = true
|
||||
for (const state of this.work.values()) {
|
||||
if (state.pending?.registration === registration) delete state.pending
|
||||
if (state.active?.registration === registration) {
|
||||
state.active.controller.abort(new Error(`session-title provider "${provider.id}" was disposed`))
|
||||
}
|
||||
}
|
||||
await this.drain(registration.active)
|
||||
if (this.registration === registration) this.registration = undefined
|
||||
}
|
||||
}.bind(this), 'sessionTitle.register()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** Schedule fallback creation and any provider cadence for one eligible event. */
|
||||
private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
|
||||
if (!this.serviceActive()) return
|
||||
if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
|
||||
const registration = this.registration
|
||||
if (registration !== undefined && !registration.closing) {
|
||||
const messages = collectSessionTitleMessages(session.events, event.seq)
|
||||
const shouldSchedule = registration.provider.automatic === 'all-user-messages'
|
||||
|| (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
|
||||
if (shouldSchedule) {
|
||||
const state = this.stateFor(session)
|
||||
const revision = this.supersede(state, 'newer user message superseded title generation')
|
||||
state.pending = { registration, revision, throughSeq: event.seq }
|
||||
}
|
||||
}
|
||||
this.defer(async () => {
|
||||
try {
|
||||
await this.ensureFallback(session)
|
||||
} catch (error: unknown) {
|
||||
if (!this.serviceActive()) return
|
||||
this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Start pending automatic work only after its exact main-request route is logged. */
|
||||
private onRequestHeader(session: Session, event: Extract<SessionEvent, { type: 'request/header' }>): void {
|
||||
if (!this.serviceActive()) return
|
||||
const state = this.work.get(session)
|
||||
const pending = state?.pending
|
||||
if (state === undefined || pending === undefined || pending.throughSeq >= event.seq) return
|
||||
const route = {
|
||||
provider: event.data.header.config.provider,
|
||||
model: event.data.header.config.model,
|
||||
}
|
||||
this.startPending(session, state, pending, route)
|
||||
}
|
||||
|
||||
/** Start unchanged-route work from the marked loop request after its header fold is current. */
|
||||
private onMainRequest(options: GenerateOptions): void {
|
||||
if (!this.serviceActive() || options.sessionId === undefined || !isAgentLoopRequest(options)) return
|
||||
const session = this.ctx.sessions.get(options.sessionId)
|
||||
const state = session === undefined ? undefined : this.work.get(session)
|
||||
const pending = state?.pending
|
||||
if (session === undefined || state === undefined || pending === undefined) return
|
||||
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
|
||||
const route = session.requestHeader()?.config
|
||||
if (boundary?.type !== 'step/start'
|
||||
|| boundary.seq <= pending.throughSeq
|
||||
|| route?.provider !== options.provider
|
||||
|| route.model !== options.model) return
|
||||
this.startPending(session, state, pending, { provider: options.provider, model: options.model })
|
||||
}
|
||||
|
||||
/** Consume one pending revision and schedule its non-blocking provider call. */
|
||||
private startPending(
|
||||
session: Session,
|
||||
state: SessionTitleWorkState,
|
||||
pending: PendingAutomaticWork,
|
||||
route: SessionTitleModelProvenance,
|
||||
): void {
|
||||
delete state.pending
|
||||
this.defer(async () => {
|
||||
if (this.registration !== pending.registration
|
||||
|| pending.registration.closing
|
||||
|| this.work.get(session) !== state
|
||||
|| state.revision !== pending.revision) return
|
||||
const work = this.activate(pending, state)
|
||||
try {
|
||||
await this.startProvider(session, work, route)
|
||||
} catch (error: unknown) {
|
||||
if (work.signal.aborted || !this.serviceActive()) return
|
||||
this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Start one tracked provider call after publishing its active revision. */
|
||||
private startProvider(
|
||||
session: Session,
|
||||
work: ActiveProviderWork,
|
||||
route?: SessionTitleModelProvenance,
|
||||
): Promise<SessionTitleSnapshot | undefined> {
|
||||
const run = Promise.resolve().then(() => this.runProvider(session, work, route))
|
||||
return this.track(run, work.registration)
|
||||
}
|
||||
|
||||
/** Execute and durably accept one current provider revision. */
|
||||
private async runProvider(
|
||||
session: Session,
|
||||
work: ActiveProviderWork,
|
||||
route?: SessionTitleModelProvenance,
|
||||
): Promise<SessionTitleSnapshot | undefined> {
|
||||
try {
|
||||
this.assertCurrent(session, work)
|
||||
await this.ensureFallback(session)
|
||||
this.assertCurrent(session, work)
|
||||
const messages = collectSessionTitleMessages(session.events, work.throughSeq)
|
||||
const result = await work.registration.provider.generate({
|
||||
session,
|
||||
messages,
|
||||
...route === undefined ? {} : { route },
|
||||
signal: work.signal,
|
||||
})
|
||||
this.assertCurrent(session, work)
|
||||
const accepted = this.validateResult(result, messages)
|
||||
await appendSessionTitleOutOfBand(this.ctx, session, 'session/title', {
|
||||
title: accepted.title,
|
||||
messageSeqs: [...accepted.messageSeqs],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: work.registration.provider.id,
|
||||
...accepted.model === undefined ? {} : { model: accepted.model },
|
||||
},
|
||||
}, work.signal)
|
||||
return this.get(session)
|
||||
} finally {
|
||||
const state = this.work.get(session)
|
||||
if (state?.active === work) delete state.active
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and normalize provider output against the supplied message snapshot. */
|
||||
private validateResult(
|
||||
result: unknown,
|
||||
messages: readonly SessionTitleUserMessage[],
|
||||
): SessionTitleProviderResult {
|
||||
if (result === null || typeof result !== 'object') {
|
||||
throw new Error('session-title provider returned an invalid result')
|
||||
}
|
||||
const candidate = result as Record<string, unknown>
|
||||
if (typeof candidate.title !== 'string') throw new Error('session-title provider title must be a string')
|
||||
const title = normalizeSessionTitle(candidate.title, this.config.maxTitleBytes)
|
||||
if (title.length === 0) throw new Error('session-title provider returned an empty title')
|
||||
if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) {
|
||||
throw new Error('session-title provider must identify at least one source message seq')
|
||||
}
|
||||
const messageSeqs: number[] = []
|
||||
const order = new Map(messages.map((message, index) => [message.seq, index]))
|
||||
let previous = -1
|
||||
for (const seq of candidate.messageSeqs as unknown[]) {
|
||||
if (typeof seq !== 'number') {
|
||||
throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
|
||||
}
|
||||
const index = order.get(seq)
|
||||
if (!Number.isSafeInteger(seq) || seq < 0 || index === undefined || index <= previous) {
|
||||
throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
|
||||
}
|
||||
messageSeqs.push(seq)
|
||||
previous = index
|
||||
}
|
||||
const modelCandidate = candidate.model
|
||||
let model: SessionTitleModelProvenance | undefined
|
||||
if (modelCandidate !== undefined) {
|
||||
if (modelCandidate === null || typeof modelCandidate !== 'object') {
|
||||
throw new Error('session-title provider model provenance requires non-empty provider and model')
|
||||
}
|
||||
const record = modelCandidate as Record<string, unknown>
|
||||
if (typeof record.provider !== 'string' || record.provider.length === 0
|
||||
|| typeof record.model !== 'string' || record.model.length === 0) {
|
||||
throw new Error('session-title provider model provenance requires non-empty provider and model')
|
||||
}
|
||||
model = { provider: record.provider, model: record.model }
|
||||
}
|
||||
return {
|
||||
title,
|
||||
messageSeqs,
|
||||
...(model === undefined ? {} : { model }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail a completion whose provider, revision, session, or signal is stale. */
|
||||
private assertCurrent(session: Session, work: ActiveProviderWork): void {
|
||||
this.assertServiceActive()
|
||||
work.signal.throwIfAborted()
|
||||
const state = this.work.get(session)
|
||||
/* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
|
||||
* the work signal before changing this state. */
|
||||
if (this.registration !== work.registration
|
||||
|| state?.active !== work
|
||||
|| state.revision !== work.revision
|
||||
|| this.ctx.sessions.get(session.id) !== session) {
|
||||
throw new Error('session title generation state changed without cancellation')
|
||||
}
|
||||
}
|
||||
|
||||
/** Create and publish an active provider call from one fixed revision. */
|
||||
private activate(
|
||||
pending: PendingAutomaticWork,
|
||||
state: SessionTitleWorkState,
|
||||
upstream?: AbortSignal,
|
||||
): ActiveProviderWork {
|
||||
const controller = new AbortController()
|
||||
const signal = upstream === undefined
|
||||
? AbortSignal.any([controller.signal, this.lifetime.signal])
|
||||
: AbortSignal.any([controller.signal, this.lifetime.signal, upstream])
|
||||
const work: ActiveProviderWork = { ...pending, controller, signal }
|
||||
state.active = work
|
||||
return work
|
||||
}
|
||||
|
||||
/** Abort older active work and reserve the next session-local revision. */
|
||||
private supersede(state: SessionTitleWorkState, reason: string): number {
|
||||
state.active?.controller.abort(new Error(reason))
|
||||
delete state.pending
|
||||
state.revision += 1
|
||||
return state.revision
|
||||
}
|
||||
|
||||
/** Return mutable work state for one session. */
|
||||
private stateFor(session: Session): SessionTitleWorkState {
|
||||
let state = this.work.get(session)
|
||||
if (state === undefined) {
|
||||
state = { revision: 0 }
|
||||
this.work.set(session, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** Queue detached service work and retain it through service disposal. */
|
||||
private defer(task: () => Promise<void>): void {
|
||||
const run = Promise.resolve().then(async () => {
|
||||
if (!this.serviceActive()) return
|
||||
await task()
|
||||
})
|
||||
void this.track(run)
|
||||
}
|
||||
|
||||
/** Retain one promise until settlement for service and optional provider teardown. */
|
||||
private track<T>(run: Promise<T>, registration?: ProviderRegistration): Promise<T> {
|
||||
this.inFlight.add(run)
|
||||
registration?.active.add(run)
|
||||
const settled = (): void => {
|
||||
this.inFlight.delete(run)
|
||||
registration?.active.delete(run)
|
||||
}
|
||||
void run.then(settled, settled)
|
||||
return run
|
||||
}
|
||||
|
||||
/** Await every current and settling promise in one lifecycle registry. */
|
||||
private async drain(active: Set<Promise<unknown>>): Promise<void> {
|
||||
while (active.size > 0) await Promise.allSettled([...active])
|
||||
}
|
||||
|
||||
/** Whether the owning plugin fiber can still start or commit title work. */
|
||||
private serviceActive(): boolean {
|
||||
return !this.lifetime.signal.aborted
|
||||
&& this.ownerFiber.uid !== null
|
||||
&& this.ownerFiber.state === FiberState.ACTIVE
|
||||
}
|
||||
|
||||
/** Reject work once the owning plugin fiber has begun unloading. */
|
||||
private assertServiceActive(): void {
|
||||
if (!this.serviceActive()) throw new Error('session-title service disposed')
|
||||
}
|
||||
|
||||
/** Reject malformed provider registrations before publishing an effect. */
|
||||
private validateProvider(provider: unknown): asserts provider is SessionTitleProvider {
|
||||
if (provider === null || typeof provider !== 'object') {
|
||||
throw new Error('session-title provider must be an object')
|
||||
}
|
||||
const candidate = provider as Record<string, unknown>
|
||||
if (typeof candidate.id !== 'string' || candidate.id.length === 0) {
|
||||
throw new Error('session-title provider id must be a non-empty string')
|
||||
}
|
||||
if (candidate.automatic !== 'first-message' && candidate.automatic !== 'all-user-messages') {
|
||||
throw new Error('session-title provider automatic mode is invalid')
|
||||
}
|
||||
if (typeof candidate.generate !== 'function') {
|
||||
throw new Error(`session-title provider "${candidate.id}" requires generate()`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the first deterministic fallback if the session still lacks a title. */
|
||||
private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
|
||||
this.assertServiceActive()
|
||||
const current = this.get(session)
|
||||
if (current !== undefined) return current
|
||||
const [first] = collectSessionTitleMessages(session.events)
|
||||
if (first === undefined) return undefined
|
||||
const title = fallbackSessionTitle(
|
||||
first.text,
|
||||
this.config.fallbackMaxWords,
|
||||
this.config.fallbackMaxBytes,
|
||||
)
|
||||
if (title.length === 0) return undefined
|
||||
const state = this.stateFor(session)
|
||||
if (state.fallback !== undefined) return state.fallback
|
||||
const fallback = appendSessionTitleOutOfBand(this.ctx, session, 'session/title', {
|
||||
title,
|
||||
messageSeqs: [first.seq],
|
||||
source: { kind: 'fallback' },
|
||||
}, this.lifetime.signal).then(() => this.get(session))
|
||||
state.fallback = fallback
|
||||
try {
|
||||
return await fallback
|
||||
} finally {
|
||||
delete state.fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionTitleService
|
||||
30
packages/session-title/session-title/src/invariant.ts
Normal file
30
packages/session-title/session-title/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title`.
|
||||
* @module @deepseek-ai/dsh-session-title/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-title-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the service validates provider revisions before their single durable
|
||||
* append, and its remaining provider lifecycle state is process-local and covered by package tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
74
packages/session-title/session-title/src/normalize.ts
Normal file
74
packages/session-title/session-title/src/normalize.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/** Title text normalization and UTF-8-safe truncation. */
|
||||
|
||||
/** Operating-system-command escape sequences, including unterminated tails. */
|
||||
const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
|
||||
/** Control-sequence-introducer escapes such as SGR color codes. */
|
||||
const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
|
||||
/** Remaining two-byte ESC control sequences. */
|
||||
const ESC_SEQUENCE = /\u001B[@-_]/gu
|
||||
/** Non-whitespace C0/C1 control characters. */
|
||||
const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu
|
||||
/** Directional and invisible controls that can make a displayed title deceptive. */
|
||||
const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu
|
||||
|
||||
/** Reject an invalid public text limit. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove controls and produce one trimmed, whitespace-normalized line. */
|
||||
function cleanTitleText(input: string): string {
|
||||
return input
|
||||
.replace(OSC_SEQUENCE, '')
|
||||
.replace(CSI_SEQUENCE, '')
|
||||
.replace(ESC_SEQUENCE, '')
|
||||
.replace(CONTROL_CHARACTER, '')
|
||||
.replace(DIRECTIONAL_CONTROL, '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a UTF-8 byte budget without splitting a Unicode code point.
|
||||
* @param input - normalized title text.
|
||||
* @param maxBytes - positive UTF-8 byte budget.
|
||||
* @returns the longest leading code-point prefix within the budget.
|
||||
*/
|
||||
export function truncateTitleUtf8(input: string, maxBytes: number): string {
|
||||
assertPositiveInteger('maxBytes', maxBytes)
|
||||
if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input
|
||||
let used = 0
|
||||
let output = ''
|
||||
for (const character of input) {
|
||||
const bytes = Buffer.byteLength(character, 'utf8')
|
||||
if (used + bytes > maxBytes) break
|
||||
output += character
|
||||
used += bytes
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one accepted session title and enforce its UTF-8 byte budget.
|
||||
* @param input - untrusted title text.
|
||||
* @param maxBytes - positive maximum encoded size.
|
||||
* @returns a terminal-safe one-line title, possibly empty after sanitization.
|
||||
*/
|
||||
export function normalizeSessionTitle(input: string, maxBytes: number): string {
|
||||
return truncateTitleUtf8(cleanTitleText(input), maxBytes).trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the deterministic first-message fallback.
|
||||
* @param input - text from the first eligible human message.
|
||||
* @param maxWords - positive whitespace-delimited word cap.
|
||||
* @param maxBytes - positive UTF-8 byte cap.
|
||||
* @returns the normalized leading words within both limits.
|
||||
*/
|
||||
export function fallbackSessionTitle(input: string, maxWords: number, maxBytes: number): string {
|
||||
assertPositiveInteger('maxWords', maxWords)
|
||||
const words = cleanTitleText(input).split(' ').filter(Boolean).slice(0, maxWords)
|
||||
return truncateTitleUtf8(words.join(' '), maxBytes).trimEnd()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
} as const
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
|
||||
const session = ctx.sessions.create(id)
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'Persist this session title' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', session)
|
||||
}
|
||||
|
||||
async function expectPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
|
||||
const loaded = await ctx.sessionPersistence.load(id)
|
||||
expect(foldSessionTitle(loaded.events)).toMatchObject({
|
||||
title: 'Persist this session title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
eventSeq: 2,
|
||||
})
|
||||
expect(loaded.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'session/title',
|
||||
'turn/end',
|
||||
])
|
||||
}
|
||||
|
||||
describe('session title persistence round trips', () => {
|
||||
it('round-trips through a remounted JSONL backend', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-title-jsonl-'))
|
||||
roots.push(root)
|
||||
const id = SessionId('title-jsonl')
|
||||
const writer = new Context()
|
||||
await writer.plugin(SessionStore)
|
||||
await writer.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
await writer.plugin(SessionTitleService, CONFIG)
|
||||
await appendPersistedTitle(writer, id)
|
||||
await writer.fiber.dispose()
|
||||
|
||||
const reader = new Context()
|
||||
await reader.plugin(SessionStore)
|
||||
await reader.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
await expectPersistedTitle(reader, id)
|
||||
await reader.fiber.dispose()
|
||||
})
|
||||
|
||||
it('round-trips through a remounted SQLite backend', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-title-sqlite-'))
|
||||
roots.push(root)
|
||||
const path = join(root, 'sessions.db')
|
||||
const id = SessionId('title-sqlite')
|
||||
const writer = new Context()
|
||||
await writer.plugin(SessionStore)
|
||||
await writer.plugin(SessionPersistenceSqlite, { path })
|
||||
await writer.plugin(SessionTitleService, CONFIG)
|
||||
await appendPersistedTitle(writer, id)
|
||||
await writer.fiber.dispose()
|
||||
|
||||
const reader = new Context()
|
||||
await reader.plugin(SessionStore)
|
||||
await reader.plugin(SessionPersistenceSqlite, { path })
|
||||
await expectPersistedTitle(reader, id)
|
||||
await reader.fiber.dispose()
|
||||
})
|
||||
})
|
||||
384
packages/session-title/session-title/tests/provider.spec.ts
Normal file
384
packages/session-title/session-title/tests/provider.spec.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LlmService, { deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, {
|
||||
SessionTitleProviderId,
|
||||
type SessionTitleProvider,
|
||||
type SessionTitleProviderRequest,
|
||||
type SessionTitleProviderResult,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 24,
|
||||
maxTitleBytes: 24,
|
||||
} as const
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
reject(error: unknown): void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((accept, decline) => {
|
||||
resolve = accept
|
||||
reject = decline
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
|
||||
return session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function appendRoute(session: ReturnType<Context['sessions']['create']>, reason: 'initial' | 'change' = 'initial'): void {
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main-route', model: 'chat-model' } },
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
describe('SessionTitleService provider lifecycle', () => {
|
||||
it('inherits title events across forks, skips first-message retitling, and lets all-messages update later', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const parent = ctx.sessions.create(SessionId('title-parent'))
|
||||
parent.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt')
|
||||
await settle()
|
||||
parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const child = ctx.sessions.fork(parent, undefined, SessionId('title-child'))
|
||||
expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent))
|
||||
expect(child.events.find(event => event.type === 'session/title'))
|
||||
.toEqual(parent.events.find(event => event.type === 'session/title'))
|
||||
|
||||
const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
|
||||
title: 'Should not run',
|
||||
messageSeqs: [request.messages[0]!.seq],
|
||||
}))
|
||||
const disposeFirst = ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('fork-first'),
|
||||
automatic: 'first-message',
|
||||
generate: firstGenerate,
|
||||
})
|
||||
child.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const childMessage = appendHumanPrompt(child, 'Child follow-up prompt')
|
||||
await settle()
|
||||
appendRoute(child)
|
||||
await settle()
|
||||
child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
expect(firstGenerate).not.toHaveBeenCalled()
|
||||
await disposeFirst()
|
||||
|
||||
const allGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
|
||||
title: 'Fork all prompts',
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}))
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('fork-all'),
|
||||
automatic: 'all-user-messages',
|
||||
generate: allGenerate,
|
||||
})
|
||||
child.append('turn/start', {
|
||||
turn: 3,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const latestMessage = appendHumanPrompt(child, 'Retitle the fork now')
|
||||
await settle()
|
||||
appendRoute(child, 'change')
|
||||
await settle()
|
||||
child.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
||||
|
||||
expect(allGenerate).toHaveBeenCalledOnce()
|
||||
expect(ctx.sessionTitle.get(child)).toMatchObject({
|
||||
title: 'Fork all prompts',
|
||||
messageSeqs: [inheritedMessage.seq, childMessage.seq, latestMessage.seq],
|
||||
source: { kind: 'provider', provider: SessionTitleProviderId('fork-all') },
|
||||
})
|
||||
expect(ctx.sessionTitle.get(parent)?.title).toBe('Inherited title prompt')
|
||||
})
|
||||
|
||||
it('runs a first-message provider once after the routed request and retries only through refresh', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
const provider: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('first-model'),
|
||||
automatic: 'first-message',
|
||||
async generate(request) {
|
||||
requests.push(request)
|
||||
return {
|
||||
title: '\u001B[31m A model-generated title that is too long ',
|
||||
messageSeqs: [request.messages[0]!.seq],
|
||||
model: { provider: 'aux-route', model: 'title-model' },
|
||||
}
|
||||
},
|
||||
}
|
||||
ctx.sessionTitle.register(provider)
|
||||
const session = ctx.sessions.create(SessionId('first-provider'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const first = appendHumanPrompt(session, 'Explain asynchronous title generation')
|
||||
await settle()
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]).toMatchObject({
|
||||
session,
|
||||
messages: [{ seq: first.seq, text: 'Explain asynchronous title generation' }],
|
||||
route: { provider: 'main-route', model: 'chat-model' },
|
||||
})
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'A model-generated title',
|
||||
messageSeqs: [first.seq],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('first-model'),
|
||||
model: { provider: 'aux-route', model: 'title-model' },
|
||||
},
|
||||
})
|
||||
|
||||
const second = appendHumanPrompt(session, 'A later prompt')
|
||||
appendRoute(session, 'change')
|
||||
await settle()
|
||||
expect(requests).toHaveLength(1)
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq])
|
||||
})
|
||||
|
||||
it('rejects a second provider and drains stale work when the winner is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const pending = deferred<SessionTitleProviderResult>()
|
||||
let observedSignal: AbortSignal | undefined
|
||||
const first: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('winner'),
|
||||
automatic: 'all-user-messages',
|
||||
generate(request) {
|
||||
observedSignal = request.signal
|
||||
return pending.promise
|
||||
},
|
||||
}
|
||||
const dispose = ctx.sessionTitle.register(first)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('duplicate'),
|
||||
automatic: 'first-message',
|
||||
generate: async () => ({ title: 'duplicate', messageSeqs: [0] }),
|
||||
})).toThrow(/already registered/)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('dispose-provider'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const message = appendHumanPrompt(session, 'Generate this title')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
expect(observedSignal?.aborted).toBe(false)
|
||||
|
||||
const disposal = dispose()
|
||||
expect(observedSignal?.aborted).toBe(true)
|
||||
let disposed = false
|
||||
void disposal.then(() => { disposed = true })
|
||||
await settle()
|
||||
expect(disposed).toBe(false)
|
||||
pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] })
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
|
||||
const replacement: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('replacement'),
|
||||
automatic: 'first-message',
|
||||
generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }),
|
||||
}
|
||||
const disposeReplacement = ctx.sessionTitle.register(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const firstResult = deferred<SessionTitleProviderResult>()
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
const provider: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('all-model'),
|
||||
automatic: 'all-user-messages',
|
||||
generate(request) {
|
||||
requests.push(request)
|
||||
if (requests.length === 1) return firstResult.promise
|
||||
return Promise.resolve({
|
||||
title: 'Newest complete title',
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
})
|
||||
},
|
||||
}
|
||||
ctx.sessionTitle.register(provider)
|
||||
const session = ctx.sessions.create(SessionId('supersede'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const first = appendHumanPrompt(session, 'First prompt')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
|
||||
const second = appendHumanPrompt(session, 'Second prompt')
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
appendRoute(session, 'change')
|
||||
await settle()
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'Newest complete title',
|
||||
messageSeqs: [first.seq, second.seq],
|
||||
})
|
||||
|
||||
firstResult.resolve({ title: 'Old ignored result', messageSeqs: [first.seq] })
|
||||
await settle()
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe('Newest complete title')
|
||||
})
|
||||
|
||||
it('runs an all-messages revision when the next main request reuses its logged header', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('unchanged-route'),
|
||||
automatic: 'all-user-messages',
|
||||
async generate(request) {
|
||||
requests.push(request)
|
||||
return {
|
||||
title: `Revision ${requests.length}`,
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('unchanged-route'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const first = appendHumanPrompt(session, 'First routed prompt')
|
||||
await settle()
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const second = appendHumanPrompt(session, 'Second prompt on the same route')
|
||||
await settle()
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({
|
||||
provider: 'main-route',
|
||||
model: 'chat-model',
|
||||
messages: session.deriveMessages(),
|
||||
sessionId: session.id,
|
||||
})))
|
||||
await settle()
|
||||
|
||||
expect(session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]).toMatchObject({
|
||||
messages: [
|
||||
{ seq: first.seq, text: 'First routed prompt' },
|
||||
{ seq: second.seq, text: 'Second prompt on the same route' },
|
||||
],
|
||||
route: { provider: 'main-route', model: 'chat-model' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores model streams that are not a matching loop request', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
|
||||
title: 'Unexpected title',
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}))
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('request-filter'),
|
||||
automatic: 'all-user-messages',
|
||||
generate,
|
||||
})
|
||||
const options = { provider: 'main-route', model: 'chat-model', messages: [] }
|
||||
|
||||
void ctx.llm.stream(deepFreeze(options))
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: SessionId('missing') })))
|
||||
const quiet = ctx.sessions.create(SessionId('quiet'))
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: quiet.id })))
|
||||
const pending = ctx.sessions.create(SessionId('unmatched-boundary'))
|
||||
pending.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
appendHumanPrompt(pending, 'Wait for a matching request boundary')
|
||||
await settle()
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: pending.id })))
|
||||
await settle()
|
||||
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('contains automatic failures but lets explicit refresh reject', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const provider: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('failing'),
|
||||
automatic: 'all-user-messages',
|
||||
generate: async () => { throw new Error('title backend failed') },
|
||||
}
|
||||
ctx.sessionTitle.register(provider)
|
||||
const session = ctx.sessions.create(SessionId('failure'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
appendHumanPrompt(session, 'Keep a fallback')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('automatic title generation failed'))
|
||||
await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow('title backend failed')
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,583 @@
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, {
|
||||
appendSessionTitleOutOfBand,
|
||||
SessionTitleProviderId,
|
||||
type Config,
|
||||
type SessionTitleProvider,
|
||||
type SessionTitleProviderRequest,
|
||||
type SessionTitleProviderResult,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
'test/title-provider-request': { revision: number }
|
||||
}
|
||||
|
||||
interface OutOfBandSessionEventMap {
|
||||
'test/title-provider-request': true
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
} as const
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((accept) => { resolve = accept })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function setup(config: Config = CONFIG): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function startSession(ctx: Context, id: string): ReturnType<Context['sessions']['create']> {
|
||||
const session = ctx.sessions.create(SessionId(id))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
function appendPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
|
||||
return session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('SessionTitleService configuration and refresh boundaries', () => {
|
||||
it('requires explicit positive limits with a fallback cap no larger than the accepted-title cap', () => {
|
||||
expect(() => new SessionTitleService(new Context(), undefined as never))
|
||||
.toThrow('configuration is required')
|
||||
expect(() => new SessionTitleService(new Context(), null as never))
|
||||
.toThrow('configuration is required')
|
||||
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 0 }))
|
||||
.toThrow(/fallbackMaxWords must be a positive integer/)
|
||||
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 1.5 }))
|
||||
.toThrow(/fallbackMaxWords must be a positive integer/)
|
||||
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxBytes: 81 }))
|
||||
.toThrow(/fallbackMaxBytes must not exceed maxTitleBytes/)
|
||||
})
|
||||
|
||||
it('returns no title for empty input with or without a provider, and rejects detached or pre-aborted refreshes', async () => {
|
||||
const fallbackOnly = await setup()
|
||||
const empty = fallbackOnly.sessions.create(SessionId('empty-fallback'))
|
||||
await expect(fallbackOnly.sessionTitle.refresh(empty)).resolves.toBeUndefined()
|
||||
|
||||
const withProvider = await setup()
|
||||
const generate = vi.fn(async (): Promise<SessionTitleProviderResult> => ({
|
||||
title: 'unused',
|
||||
messageSeqs: [0],
|
||||
}))
|
||||
withProvider.sessionTitle.register({
|
||||
id: SessionTitleProviderId('empty-provider'),
|
||||
automatic: 'first-message',
|
||||
generate,
|
||||
})
|
||||
const providerEmpty = withProvider.sessions.create(SessionId('empty-provider'))
|
||||
await expect(withProvider.sessionTitle.refresh(providerEmpty)).resolves.toBeUndefined()
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
|
||||
await expect(withProvider.sessionTitle.refresh(new Session(SessionId('detached'))))
|
||||
.rejects.toThrow(/not live in this store/)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('already cancelled'))
|
||||
await expect(withProvider.sessionTitle.refresh(providerEmpty, controller.signal))
|
||||
.rejects.toThrow('already cancelled')
|
||||
})
|
||||
|
||||
it('passes an absent route and caller cancellation into explicit generation', async () => {
|
||||
const ctx = await setup()
|
||||
let observed: SessionTitleProviderRequest | undefined
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('explicit-no-route'),
|
||||
automatic: 'first-message',
|
||||
async generate(request) {
|
||||
observed = request
|
||||
return { title: 'Explicit title', messageSeqs: [request.messages[0]!.seq] }
|
||||
},
|
||||
})
|
||||
const session = startSession(ctx, 'explicit-no-route')
|
||||
appendPrompt(session, 'Refresh before any request header')
|
||||
await settle()
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(ctx.sessionTitle.refresh(session, controller.signal))
|
||||
.resolves.toMatchObject({ title: 'Explicit title' })
|
||||
expect(observed?.route).toBeUndefined()
|
||||
expect(observed?.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('propagates explicit cancellation and session disposal to active work', async () => {
|
||||
const callerCtx = await setup()
|
||||
const callerPending = deferred<SessionTitleProviderResult>()
|
||||
let callerSignal: AbortSignal | undefined
|
||||
callerCtx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('caller-cancel'),
|
||||
automatic: 'first-message',
|
||||
generate(request) {
|
||||
callerSignal = request.signal
|
||||
return callerPending.promise
|
||||
},
|
||||
})
|
||||
const callerSession = startSession(callerCtx, 'caller-cancel')
|
||||
const callerMessage = appendPrompt(callerSession, 'Cancel this refresh')
|
||||
await settle()
|
||||
const controller = new AbortController()
|
||||
const refresh = callerCtx.sessionTitle.refresh(callerSession, controller.signal)
|
||||
await settle()
|
||||
controller.abort(new Error('caller cancelled'))
|
||||
callerPending.resolve({ title: 'ignored', messageSeqs: [callerMessage.seq] })
|
||||
await expect(refresh).rejects.toThrow('caller cancelled')
|
||||
expect(callerSignal?.aborted).toBe(true)
|
||||
|
||||
const disposeCtx = await setup()
|
||||
const disposePending = deferred<SessionTitleProviderResult>()
|
||||
let disposeSignal: AbortSignal | undefined
|
||||
disposeCtx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('session-dispose'),
|
||||
automatic: 'first-message',
|
||||
generate(request) {
|
||||
disposeSignal = request.signal
|
||||
return disposePending.promise
|
||||
},
|
||||
})
|
||||
const disposed = disposeCtx.sessions.prepare(SessionId('session-dispose'))
|
||||
const detach = disposeCtx.sessions.enter(disposed)
|
||||
disposeCtx.sessions.announce(disposed)
|
||||
disposed.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const disposedMessage = appendPrompt(disposed, 'Dispose this session')
|
||||
await settle()
|
||||
const disposedRefresh = disposeCtx.sessionTitle.refresh(disposed)
|
||||
await settle()
|
||||
detach()
|
||||
disposePending.resolve({ title: 'ignored', messageSeqs: [disposedMessage.seq] })
|
||||
await expect(disposedRefresh).rejects.toThrow(/session disposed/)
|
||||
expect(disposeSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects fallback refresh cancellation that arrives during durability flush', async () => {
|
||||
const ctx = await setup()
|
||||
const seed = new Session(SessionId('fallback-cancel-seed'))
|
||||
seed.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const source = appendPrompt(seed, 'Persist this fallback despite caller cancellation')
|
||||
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const session = ctx.sessions.create(SessionId('fallback-cancel'), { seed: seed.events })
|
||||
const flushStarted = deferred<undefined>()
|
||||
const releaseFlush = deferred<undefined>()
|
||||
ctx.on('session/flush', async (subject) => {
|
||||
if (subject !== session) return
|
||||
flushStarted.resolve(undefined)
|
||||
await releaseFlush.promise
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
const refresh = ctx.sessionTitle.refresh(session, controller.signal)
|
||||
await flushStarted.promise
|
||||
controller.abort(new Error('cancelled while fallback flushed'))
|
||||
releaseFlush.resolve(undefined)
|
||||
|
||||
await expect(refresh).rejects.toThrow('cancelled while fallback flushed')
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
messageSeqs: [source.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
})
|
||||
|
||||
it('shares one durable fallback across concurrent refreshes', async () => {
|
||||
const ctx = await setup()
|
||||
const seed = new Session(SessionId('fallback-concurrency-seed'))
|
||||
seed.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const source = appendPrompt(seed, 'Create exactly one fallback title')
|
||||
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', (subject) => {
|
||||
if (subject === session) flushes += 1
|
||||
})
|
||||
|
||||
const results = await Promise.all([
|
||||
ctx.sessionTitle.refresh(session),
|
||||
ctx.sessionTitle.refresh(session),
|
||||
])
|
||||
|
||||
expect(results[0]).toEqual(results[1])
|
||||
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
|
||||
expect(session.events.filter(event => event.type === 'turn/start'
|
||||
&& event.data.trigger.kind === 'session-title')).toHaveLength(1)
|
||||
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
|
||||
expect(flushes).toBe(1)
|
||||
})
|
||||
|
||||
it('reserves overlapping refresh order before fallback durability settles', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const seed = new Session(SessionId('refresh-order-seed'))
|
||||
seed.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const source = appendPrompt(seed, 'Keep the newest explicit refresh')
|
||||
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const session = ctx.sessions.create(SessionId('refresh-order'), { seed: seed.events })
|
||||
const flushStarted = deferred<undefined>()
|
||||
const releaseFlush = deferred<undefined>()
|
||||
let flushCount = 0
|
||||
ctx.on('session/flush', async (subject) => {
|
||||
if (subject !== session || ++flushCount !== 1) return
|
||||
flushStarted.resolve(undefined)
|
||||
await releaseFlush.promise
|
||||
})
|
||||
const result = deferred<SessionTitleProviderResult>()
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('refresh-order'),
|
||||
automatic: 'first-message',
|
||||
generate(request) {
|
||||
requests.push(request)
|
||||
return result.promise
|
||||
},
|
||||
})
|
||||
|
||||
const older = ctx.sessionTitle.refresh(session)
|
||||
const olderOutcome = older.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
await flushStarted.promise
|
||||
const newer = ctx.sessionTitle.refresh(session)
|
||||
await settle()
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.signal.aborted).toBe(false)
|
||||
|
||||
releaseFlush.resolve(undefined)
|
||||
await settle()
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.signal.aborted).toBe(false)
|
||||
result.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] })
|
||||
await expect(newer).resolves.toMatchObject({ title: 'Newest explicit title' })
|
||||
const olderError = await olderOutcome
|
||||
expect(olderError).toBeInstanceOf(Error)
|
||||
if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject')
|
||||
expect(olderError.message).toMatch(/superseded/)
|
||||
})
|
||||
|
||||
it('serializes a newer provider write after the superseded write', async () => {
|
||||
const ctx = await setup()
|
||||
const session = startSession(ctx, 'refresh-provider-write-order')
|
||||
const source = appendPrompt(session, 'Serialize explicit provider writes')
|
||||
await settle()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const flushStarted = deferred<undefined>()
|
||||
const releaseFlush = deferred<undefined>()
|
||||
let flushCount = 0
|
||||
ctx.on('session/flush', async (subject) => {
|
||||
if (subject !== session || ++flushCount !== 1) return
|
||||
flushStarted.resolve(undefined)
|
||||
await releaseFlush.promise
|
||||
})
|
||||
let generation = 0
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('refresh-provider-write-order'),
|
||||
automatic: 'first-message',
|
||||
async generate(request) {
|
||||
generation += 1
|
||||
const revision = generation
|
||||
await appendSessionTitleOutOfBand(ctx, request.session, 'test/title-provider-request', {
|
||||
revision,
|
||||
}, request.signal)
|
||||
return {
|
||||
title: `Generated title ${revision}`,
|
||||
messageSeqs: [source.seq],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const older = ctx.sessionTitle.refresh(session)
|
||||
const olderOutcome = older.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
await flushStarted.promise
|
||||
const middle = ctx.sessionTitle.refresh(session)
|
||||
const middleOutcome = middle.then(
|
||||
value => value,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
await settle()
|
||||
|
||||
expect(generation).toBe(2)
|
||||
expect(session.events.filter(event => event.type === 'test/title-provider-request'))
|
||||
.toHaveLength(1)
|
||||
const newer = ctx.sessionTitle.refresh(session)
|
||||
const newerOutcome = newer.then(
|
||||
value => value,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
await settle()
|
||||
expect(generation).toBe(3)
|
||||
expect(session.events.filter(event => event.type === 'test/title-provider-request'))
|
||||
.toHaveLength(1)
|
||||
|
||||
releaseFlush.resolve(undefined)
|
||||
const newerResult = await newerOutcome
|
||||
expect(newerResult).toMatchObject({ title: 'Generated title 3' })
|
||||
const olderError = await olderOutcome
|
||||
expect(olderError).toBeInstanceOf(Error)
|
||||
if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject')
|
||||
expect(olderError.message).toMatch(/superseded/)
|
||||
const middleError = await middleOutcome
|
||||
expect(middleError).toBeInstanceOf(Error)
|
||||
if (!(middleError instanceof Error)) throw new Error('expected middle refresh to reject')
|
||||
expect(middleError.message).toMatch(/superseded/)
|
||||
expect(session.events.filter(event => event.type === 'test/title-provider-request').map(event => event.data.revision))
|
||||
.toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('cancels a queued fallback when the session-title service unloads', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise<unknown> } = {}
|
||||
ctx.on('internal/plugin', (subject) => {
|
||||
if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return
|
||||
appendPrompt(lifecycle.session, 'Ignore reentrant disposal prompt')
|
||||
lifecycle.session.append('request/header', {
|
||||
header: { config: { provider: 'main', model: 'main' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
lifecycle.inactiveRefresh = ctx.sessionTitle.refresh(lifecycle.session).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
})
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
lifecycle.fiber = fiber
|
||||
const session = startSession(ctx, 'service-dispose-fallback')
|
||||
lifecycle.session = session
|
||||
appendPrompt(session, 'Do not publish after service disposal')
|
||||
|
||||
await fiber.dispose()
|
||||
await settle()
|
||||
|
||||
expect(session.events.some(event => event.type === 'session/title')).toBe(false)
|
||||
const inactiveError = await lifecycle.inactiveRefresh
|
||||
expect(inactiveError).toBeInstanceOf(Error)
|
||||
if (!(inactiveError instanceof Error)) throw new Error('expected inactive refresh to reject')
|
||||
expect(inactiveError.message).toBe('session-title service disposed')
|
||||
})
|
||||
|
||||
it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const result = deferred<SessionTitleProviderResult>()
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('service-unload'),
|
||||
automatic: 'all-user-messages',
|
||||
generate(request) {
|
||||
requests.push(request)
|
||||
return result.promise
|
||||
},
|
||||
})
|
||||
const active = startSession(ctx, 'service-unload-active')
|
||||
const activeMessage = appendPrompt(active, 'Active provider work')
|
||||
await settle()
|
||||
const refresh = ctx.sessionTitle.refresh(active)
|
||||
const refreshOutcome = refresh.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
await settle()
|
||||
expect(requests).toHaveLength(1)
|
||||
const pending = startSession(ctx, 'service-unload-pending')
|
||||
appendPrompt(pending, 'Pending provider work')
|
||||
|
||||
const disposal = fiber.dispose()
|
||||
let disposed = false
|
||||
void disposal.then(() => { disposed = true })
|
||||
await settle()
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
expect(disposed).toBe(false)
|
||||
result.resolve({ title: 'Ignored service abort', messageSeqs: [activeMessage.seq] })
|
||||
await disposal
|
||||
|
||||
expect(disposed).toBe(true)
|
||||
await expect(refreshOutcome).resolves.toEqual(expect.objectContaining({ message: 'session-title service disposed' }))
|
||||
})
|
||||
|
||||
it('suppresses a queued fallback failure after service unload begins', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const session = startSession(ctx, 'service-unload-flush')
|
||||
appendPrompt(session, 'Fallback whose flush outlives the service')
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const flushStarted = deferred<undefined>()
|
||||
const releaseFlush = deferred<undefined>()
|
||||
ctx.on('session/flush', async (subject) => {
|
||||
if (subject !== session) return
|
||||
flushStarted.resolve(undefined)
|
||||
await releaseFlush.promise
|
||||
throw new Error('flush failed during service unload')
|
||||
})
|
||||
|
||||
await flushStarted.promise
|
||||
const disposal = fiber.dispose()
|
||||
releaseFlush.resolve(undefined)
|
||||
await disposal
|
||||
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when a detached session prevents queued fallback publication', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const session = ctx.sessions.prepare(SessionId('fallback-detach'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject === session && event.type === 'user/message') detach()
|
||||
})
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
appendPrompt(session, 'Detach before the fallback microtask')
|
||||
await settle()
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('fallback title update failed'))
|
||||
expect(ctx.sessionTitle.get(session)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a title absent when the byte cap cannot hold the first code point', async () => {
|
||||
const ctx = await setup({ fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 2 })
|
||||
const session = startSession(ctx, 'no-code-point')
|
||||
appendPrompt(session, '😀')
|
||||
await settle()
|
||||
expect(ctx.sessionTitle.get(session)).toBeUndefined()
|
||||
await expect(ctx.sessionTitle.refresh(session)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionTitleService provider validation and stale scheduling', () => {
|
||||
it('rejects malformed provider registrations before publishing them', async () => {
|
||||
const ctx = await setup()
|
||||
const generate = async (): Promise<SessionTitleProviderResult> => ({ title: 'title', messageSeqs: [0] })
|
||||
expect(() => ctx.sessionTitle.register(null as never)).toThrow(/must be an object/)
|
||||
expect(() => ctx.sessionTitle.register('provider' as never)).toThrow(/must be an object/)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: 1,
|
||||
automatic: 'first-message',
|
||||
generate,
|
||||
} as unknown as SessionTitleProvider)).toThrow(/id must be a non-empty string/)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId(''),
|
||||
automatic: 'first-message',
|
||||
generate,
|
||||
})).toThrow(/id must be a non-empty string/)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('bad-mode'),
|
||||
automatic: 'sometimes' as never,
|
||||
generate,
|
||||
})).toThrow(/automatic mode is invalid/)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('missing-generate'),
|
||||
automatic: 'first-message',
|
||||
generate: undefined,
|
||||
} as unknown as SessionTitleProvider)).toThrow(/requires generate/)
|
||||
})
|
||||
|
||||
it('drops automatic work when its provider is disposed before the queued start', async () => {
|
||||
const ctx = await setup()
|
||||
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
|
||||
title: 'too late',
|
||||
messageSeqs: [request.messages[0]!.seq],
|
||||
}))
|
||||
const dispose = ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('queued-dispose'),
|
||||
automatic: 'all-user-messages',
|
||||
generate,
|
||||
})
|
||||
const session = startSession(ctx, 'queued-dispose')
|
||||
appendPrompt(session, 'Queue provider work')
|
||||
await settle()
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main', model: 'main' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
const pending = startSession(ctx, 'pending-provider-dispose')
|
||||
appendPrompt(pending, 'Drop pending provider work')
|
||||
await dispose()
|
||||
await settle()
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
expect(ctx.sessionTitle.get(pending)?.source.kind).toBe('fallback')
|
||||
})
|
||||
|
||||
it('rejects malformed provider results without replacing the fallback', async () => {
|
||||
const ctx = await setup()
|
||||
let result: unknown
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('invalid-results'),
|
||||
automatic: 'first-message',
|
||||
generate: async () => result as SessionTitleProviderResult,
|
||||
})
|
||||
const session = startSession(ctx, 'invalid-results')
|
||||
const first = appendPrompt(session, 'First source')
|
||||
await settle()
|
||||
const second = appendPrompt(session, 'Second source')
|
||||
await settle()
|
||||
|
||||
const cases: Array<{ value: unknown; error: RegExp }> = [
|
||||
{ value: null, error: /invalid result/ },
|
||||
{ value: 1, error: /invalid result/ },
|
||||
{ value: { title: 1, messageSeqs: [first.seq] }, error: /title must be a string/ },
|
||||
{ value: { title: '\u001B[31m', messageSeqs: [first.seq] }, error: /empty title/ },
|
||||
{ value: { title: 'valid', messageSeqs: undefined }, error: /at least one source message/ },
|
||||
{ value: { title: 'valid', messageSeqs: [] }, error: /at least one source message/ },
|
||||
{ value: { title: 'valid', messageSeqs: ['not-a-seq'] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [1.5] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [-1] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [999] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq, first.seq] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [second.seq, first.seq] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: null }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: 'route' }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 1, model: 'm' } }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: '', model: 'm' } }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: 1 } }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: '' } }, error: /model provenance/ },
|
||||
]
|
||||
for (const item of cases) {
|
||||
result = item.value
|
||||
await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow(item.error)
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
}
|
||||
})
|
||||
})
|
||||
145
packages/session-title/session-title/tests/session-title.spec.ts
Normal file
145
packages/session-title/session-title/tests/session-title.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, {
|
||||
SessionTitleProviderId,
|
||||
fallbackSessionTitle,
|
||||
foldSessionTitle,
|
||||
normalizeSessionTitle,
|
||||
truncateTitleUtf8,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
} as const
|
||||
|
||||
async function settleTitles(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('session title normalization', () => {
|
||||
it('removes terminal controls, collapses whitespace, and applies word and UTF-8 byte caps', () => {
|
||||
expect(normalizeSessionTitle('\u001B]0;stolen\u0007 Hello\t brave\nnew world ', 80))
|
||||
.toBe('Hello brave new world')
|
||||
expect(fallbackSessionTitle('one two three four', 3, 80)).toBe('one two three')
|
||||
expect(fallbackSessionTitle('你好世界', 5, 7)).toBe('你好')
|
||||
expect(Buffer.byteLength(fallbackSessionTitle('😀😀', 5, 5), 'utf8')).toBe(4)
|
||||
})
|
||||
|
||||
it('rejects non-positive and fractional public limits', () => {
|
||||
expect(() => truncateTitleUtf8('title', 0)).toThrow(/maxBytes must be a positive integer/)
|
||||
expect(() => fallbackSessionTitle('title', 1.5, 10)).toThrow(/maxWords must be a positive integer/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionTitleService', () => {
|
||||
it('logs and folds an immediate fallback after the first eligible human text message', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('fresh'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const message = session.append('user/message', {
|
||||
content: [{ type: 'text', text: ' Build\nlog-backed session titles please ' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
await settleTitles()
|
||||
|
||||
const titleEvent = session.events.findLast(event => event.type === 'session/title')
|
||||
expect(titleEvent).toMatchObject({
|
||||
type: 'session/title',
|
||||
seq: 2,
|
||||
data: {
|
||||
title: 'Build log-backed session titles please',
|
||||
messageSeqs: [message.seq],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
})
|
||||
expect(ctx.sessionTitle.get(session)).toEqual({
|
||||
title: 'Build log-backed session titles please',
|
||||
messageSeqs: [message.seq],
|
||||
source: { kind: 'fallback' },
|
||||
eventSeq: 2,
|
||||
updatedAt: titleEvent?.time,
|
||||
})
|
||||
expect(session.deriveMessages()).toHaveLength(1)
|
||||
expect(session.surface.nodes).toEqual([message.seq])
|
||||
})
|
||||
|
||||
it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('eligibility'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'plugin text' }],
|
||||
source: { kind: 'plugin', plugin: 'seed' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'reasoning', text: 'not visible text' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: ' \n\t ' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await settleTitles()
|
||||
expect(ctx.sessionTitle.get(session)).toBeUndefined()
|
||||
|
||||
const eligible = session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first real prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await settleTitles()
|
||||
const first = ctx.sessionTitle.get(session)
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'later prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await settleTitles()
|
||||
|
||||
expect(first?.messageSeqs).toEqual([eligible.seq])
|
||||
expect(ctx.sessionTitle.get(session)).toEqual(first)
|
||||
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('folds the latest title event during replay', () => {
|
||||
const seed = new Session(SessionId('source'))
|
||||
seed.append('session/title', {
|
||||
title: 'Earlier',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
seed.append('session/title', {
|
||||
title: 'Later',
|
||||
messageSeqs: [1, 4],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('test-provider'),
|
||||
model: { provider: 'mock', model: 'title-model' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(foldSessionTitle(seed.events)).toEqual({
|
||||
title: 'Later',
|
||||
messageSeqs: [1, 4],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('test-provider'),
|
||||
model: { provider: 'mock', model: 'title-model' },
|
||||
},
|
||||
eventSeq: 1,
|
||||
updatedAt: seed.events[1]?.time,
|
||||
})
|
||||
})
|
||||
})
|
||||
33
packages/session-title/session-title/tsconfig.json
Normal file
33
packages/session-title/session-title/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@ The driver follows this sequence:
|
||||
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
|
||||
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
|
||||
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`.
|
||||
5. Read the child's own last assistant message and terminal turn reason, excluding any fork seed.
|
||||
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -175,7 +175,7 @@ function readResult(
|
||||
): SubagentResult {
|
||||
const own = child.session.events.slice(seedLength)
|
||||
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
|
||||
const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
|
||||
const lastEnd = findLastMessageTurnEnd(own)
|
||||
const output: ContentBlock[] = lastMessage?.data.content ?? []
|
||||
const recorded = toStopReason(lastEnd?.data.reason)
|
||||
// Disposal can tear the owner down before the loop records its ordinary
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
@@ -55,6 +55,36 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports the message-turn outcome when a later non-message turn completes during flush', async () => {
|
||||
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
|
||||
let injected = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (injected || session.header.parentSession === undefined) return
|
||||
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
|
||||
if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return
|
||||
injected = true
|
||||
const turn = lastEnd.data.turn + 1
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
|
||||
expect(child.session.events.findLast(event => event.type === 'turn/end'))
|
||||
.toMatchObject({ data: { reason: { kind: 'completed' } } })
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('seeds a forked child but reads only the child-owned output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
|
||||
parent.send([{ type: 'text', text: 'parent question' }])
|
||||
|
||||
@@ -6,8 +6,8 @@ Four layers, importable separately:
|
||||
|
||||
- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
|
||||
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the expected-output and purity checks, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic.
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
|
||||
|
||||
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ export {
|
||||
} from './normalize.ts'
|
||||
export {
|
||||
defineAcpSnapshotSuite,
|
||||
refreshFixtureReplacements,
|
||||
stabilizeRefreshLog,
|
||||
type Scenario,
|
||||
type SnapshotSuiteOptions,
|
||||
} from './suite.ts'
|
||||
|
||||
@@ -11,6 +11,7 @@ const CWD = '{{cwd}}'
|
||||
const SYSTEM = '{{system}}'
|
||||
const TOOLS = '{{tools}}'
|
||||
const MESSAGE_PREFIX = '{{messagePrefix}}'
|
||||
const UPDATED_AT = '{{updatedAt}}'
|
||||
|
||||
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
|
||||
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
||||
@@ -85,6 +86,8 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin
|
||||
if ('id' in frame && frame.id !== undefined && frame.id !== null) {
|
||||
frame.id = stableId(frame.id)
|
||||
}
|
||||
const update = (frame.params as { update?: Record<string, unknown> } | undefined)?.update
|
||||
if (update?.sessionUpdate === 'session_info_update') update.updatedAt = UPDATED_AT
|
||||
return scrubValue(frame, ctx) as Record<string, unknown>
|
||||
})
|
||||
return frames.map(f => JSON.stringify(f)).join('\n') + '\n'
|
||||
|
||||
@@ -420,8 +420,21 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement
|
||||
for (const { from, to } of replacements) stable = stable.split(from).join(to)
|
||||
const existingRecords = parseJsonlRecords(existing)
|
||||
const records = parseJsonlRecords(stable)
|
||||
let existingIndex = 0
|
||||
let previousEventTime: unknown
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
preserveFixtureVolatiles(records[i] as Record<string, unknown>, existingRecords[i])
|
||||
const record = records[i] as Record<string, unknown>
|
||||
const existingRecord = existingRecords[existingIndex]
|
||||
const insertedTitle = record.type === 'session/title' && existingRecord?.type !== 'session/title'
|
||||
if (insertedTitle) {
|
||||
/* v8 ignore next -- a title is turn-enclosed, so a preceding event time exists in every valid fixture. */
|
||||
if (typeof previousEventTime !== 'number') throw new Error('acp-snapshot: inserted title has no preceding event time')
|
||||
record.time = previousEventTime
|
||||
} else {
|
||||
preserveFixtureVolatiles(record, existingRecord)
|
||||
existingIndex += 1
|
||||
}
|
||||
if (typeof record.time === 'number') previousEventTime = record.time
|
||||
}
|
||||
return records.map(record => JSON.stringify(record)).join('\n') + '\n'
|
||||
}
|
||||
|
||||
@@ -55,6 +55,24 @@ describe('normalizeStdout', () => {
|
||||
expect(out).not.toContain('"id"')
|
||||
})
|
||||
|
||||
it('stabilizes the timestamp carried by session title updates', () => {
|
||||
const raw = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId: ctx.sessionIds[0],
|
||||
update: {
|
||||
sessionUpdate: 'session_info_update',
|
||||
title: 'Stable title',
|
||||
updatedAt: '2026-07-20T17:03:13.689Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
const out = normalizeStdout(raw, ctx)
|
||||
expect(out).toContain('"updatedAt":"{{updatedAt}}"')
|
||||
expect(out).not.toContain('2026-07-20T17:03:13.689Z')
|
||||
})
|
||||
|
||||
it('throws on a non-JSON stdout line (the purity check)', () => {
|
||||
const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
|
||||
expect(() => normalizeStdout(raw, ctx)).toThrow()
|
||||
|
||||
@@ -407,6 +407,36 @@ describe('refreshFixtureReplacements', () => {
|
||||
})
|
||||
|
||||
describe('stabilizeRefreshLog', () => {
|
||||
it('aligns volatile times across a newly inserted log event', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200}',
|
||||
'{"type":"turn/start","seq":0,"time":21}',
|
||||
'{"type":"user/message","seq":1,"time":22}',
|
||||
'{"type":"session/title","seq":2,"time":999}',
|
||||
'{"type":"step/start","seq":3,"time":1000}',
|
||||
'{"type":"request/header","seq":4,"time":1001}',
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"turn/start","seq":0,"time":11}',
|
||||
'{"type":"user/message","seq":1,"time":12}',
|
||||
'{"type":"step/start","seq":2,"time":13}',
|
||||
'{"type":"request/header","seq":3,"time":14}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [])).toBe([
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"turn/start","seq":0,"time":11}',
|
||||
'{"type":"user/message","seq":1,"time":12}',
|
||||
'{"type":"session/title","seq":2,"time":12}',
|
||||
'{"type":"step/start","seq":3,"time":13}',
|
||||
'{"type":"request/header","seq":4,"time":14}',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}',
|
||||
|
||||
@@ -4,13 +4,13 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `acp/` | Agent Client Protocol bridge: serves agents, commands, and live/replayed title updates to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `commands/` | Human-command registry: shared discovery metadata, scoped shadowing, cancellation, and direct UI dispatch | `ctx.commands` |
|
||||
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
|
||||
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
|
||||
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
|---|---|---|
|
||||
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
|
||||
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
|
||||
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, and tool events, and re-advertises commands |
|
||||
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands |
|
||||
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
|
||||
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
|
||||
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, and tool render intents |
|
||||
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions |
|
||||
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
|
||||
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
|
||||
| `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
@@ -55,6 +55,8 @@ The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictabl
|
||||
|
||||
ACP updates are append-only, so `llm/retry` emits a visible separator that marks preceding partial model output discarded before the next attempt streams. A terminal model-request failure emits the same discarded-output warning; replay derives both markers from the durable events.
|
||||
|
||||
A log-only `session/title` event maps to ACP `session_info_update` with `title` and the event timestamp as `updatedAt`. The same mapping runs for live events and `session/load` replay, so an asynchronously generated late title and a restored persisted title have one wire representation without entering model history.
|
||||
|
||||
## Per-session cwd
|
||||
|
||||
`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported.
|
||||
@@ -132,7 +134,7 @@ Command discovery, dispatch, and direct output never enter a model request and d
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only.
|
||||
When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, title updates, and other streamed session updates are UI-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -65,6 +66,7 @@
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -61,6 +61,8 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: adds the log-only session/title event translated below.
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
@@ -1306,6 +1308,17 @@ export function streamSessionEventUpdate(
|
||||
notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } })
|
||||
return
|
||||
}
|
||||
case 'session/title': {
|
||||
notify({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'session_info_update',
|
||||
title: event.data.title,
|
||||
updatedAt: new Date(event.time).toISOString(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
if (event.data.reason.kind !== 'error' || !('failure' in event.data.reason)) return
|
||||
const text = `\n\n[Model attempt failed; any partial output above is discarded: ${event.data.reason.failure.message}]\n\n`
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** Concatenate the text of all agent_message_chunk updates. */
|
||||
@@ -56,6 +57,31 @@ describe('acp bridge — session/load replay', () => {
|
||||
expect(userText).toBe('remember this')
|
||||
})
|
||||
|
||||
it('streams and replays the same persisted session_info_update for a title event', async () => {
|
||||
live = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const session = live.ctx.agents.get(SessionId(sessionId))!.session
|
||||
const event = await live.ctx.sessions.appendOutOfBand(session, 'session/title', {
|
||||
title: 'Durable ACP title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
}, { kind: 'session-title' })
|
||||
const expected = {
|
||||
sessionUpdate: 'session_info_update' as const,
|
||||
title: 'Durable ACP title',
|
||||
updatedAt: new Date(event.time).toISOString(),
|
||||
}
|
||||
expect(live.updates).toContainEqual(expected)
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loader.updates).toContainEqual(expected)
|
||||
})
|
||||
|
||||
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
|
||||
// Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs
|
||||
// call and result in log order so replay uses the shipping tool's same cards as live streaming.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -50,6 +51,23 @@ function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent
|
||||
}
|
||||
|
||||
describe('streamSessionEventUpdate', () => {
|
||||
it('maps a title event to session_info_update with the event timestamp', () => {
|
||||
expect(updatesFor({
|
||||
type: 'session/title',
|
||||
seq: 3,
|
||||
time: 1_725_000_000_000,
|
||||
data: {
|
||||
title: 'Log-backed titles',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
})).toEqual([{
|
||||
sessionUpdate: 'session_info_update',
|
||||
title: 'Log-backed titles',
|
||||
updatedAt: new Date(1_725_000_000_000).toISOString(),
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps assistant/chunk text-delta to agent_message_chunk', () => {
|
||||
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })))
|
||||
.toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }])
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
|
||||
|
||||
## Wire notes
|
||||
|
||||
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and persona come from `cordis.yml`.
|
||||
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later injection or plugin-owned zero-step turns still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolve } from 'node:path'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -93,7 +93,9 @@ export class HarnessSdkServer {
|
||||
this.disposers.push(ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'turn/end') {
|
||||
const rec = this.sessions.get(String(session.id))
|
||||
if (rec) rec.lastTurnEnd = event.data.reason
|
||||
if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) {
|
||||
rec.lastTurnEnd = event.data.reason
|
||||
}
|
||||
}
|
||||
this.transport.notify('session.event', { sessionId: String(session.id), event })
|
||||
}))
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -215,6 +215,64 @@ describe('HarnessSdkServer', () => {
|
||||
expect(otherHandle.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const transport = new FakeTransport()
|
||||
const server = new HarnessSdkServer(ctx, transport) as unknown as {
|
||||
prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise<unknown>
|
||||
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
const session = ctx.sessions.create(SessionId('message-outcome'))
|
||||
const agent = {
|
||||
session,
|
||||
send(content: { type: 'text'; text: string }[]) {
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
} as unknown as Agent
|
||||
server.sessions.set('message-outcome', {
|
||||
handle: { agent, dispose: () => Promise.resolve() },
|
||||
lastTurnEnd: undefined,
|
||||
activePrompt: false,
|
||||
})
|
||||
|
||||
await server.prompt({
|
||||
sessionId: 'message-outcome',
|
||||
contentBlocks: [{ type: 'text', text: 'bounded prompt' }],
|
||||
})
|
||||
|
||||
expect(transport.notifications.findLast(notification => notification.method === 'session.finished'))
|
||||
.toEqual({
|
||||
method: 'session.finished',
|
||||
params: {
|
||||
sessionId: 'message-outcome',
|
||||
status: 'error',
|
||||
reason: { kind: 'max-tokens' },
|
||||
},
|
||||
})
|
||||
await server.shutdown()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('notifies the host when a child session is created with parent lineage', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
|
||||
@@ -8,7 +8,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
|
||||
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
|
||||
|
||||
@@ -20,7 +20,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `welcome` | `ready.` | Header subtitle |
|
||||
| `welcome` | `ready.` | Header subtitle until the session has a logged title. |
|
||||
| `sessionId` | `main` | Exact shared agent/session identity driven by the terminal |
|
||||
| `showReasoning` | `true` | Render reasoning blocks |
|
||||
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
|
||||
@@ -32,7 +32,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
|
||||
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
|
||||
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
|
||||
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
|
||||
| `title` | `DeepSeek Harness` | Terminal window title |
|
||||
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
@@ -60,7 +60,7 @@ Each non-empty ordinary editor submission becomes one text block, sent with `age
|
||||
|
||||
#### Token effect
|
||||
|
||||
Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens.
|
||||
Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, the logged title, cards, Markdown rendering, status lines, plans, and help text add no tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -53,6 +54,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
FileDiff,
|
||||
TerminalCallView,
|
||||
@@ -369,7 +370,7 @@ async function readModelChoices(
|
||||
class HeaderComponent implements Component {
|
||||
constructor(
|
||||
private readonly agent: Agent,
|
||||
private readonly welcome: string,
|
||||
private readonly subtitle: () => string,
|
||||
private readonly palette: Palette,
|
||||
private readonly currentModel: () => string | undefined,
|
||||
) {}
|
||||
@@ -383,7 +384,7 @@ class HeaderComponent implements Component {
|
||||
const detail = `${model} • ${displayText(this.agent.session.id)}`
|
||||
const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`)
|
||||
const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)
|
||||
const lines = [title, this.palette.muted(displayText(this.welcome)), this.palette.dim(detail)]
|
||||
const lines = [title, this.palette.muted(displayText(this.subtitle())), this.palette.dim(detail)]
|
||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||
.map((line) => {
|
||||
const clipped = truncateToWidth(line, usable, '')
|
||||
@@ -1074,7 +1075,8 @@ export function createTuiChat(
|
||||
const now = (): number => runtime.now?.() ?? Date.now()
|
||||
|
||||
const welcome = config.welcome ?? 'ready.'
|
||||
const header = new HeaderComponent(agent, welcome, palette, () => target.current?.model)
|
||||
let sessionTitle = foldSessionTitle(agent.session.events)?.title
|
||||
const header = new HeaderComponent(agent, () => sessionTitle ?? welcome, palette, () => target.current?.model)
|
||||
const footer = new FooterComponent(
|
||||
agent,
|
||||
palette,
|
||||
@@ -1094,7 +1096,12 @@ export function createTuiChat(
|
||||
ui.addChild(editor)
|
||||
ui.addChild(footer)
|
||||
ui.setFocus(editor)
|
||||
runtime.terminal.setTitle(displayText(resolved.title))
|
||||
const updateTerminalTitle = (): void => {
|
||||
runtime.terminal.setTitle(displayText(
|
||||
sessionTitle === undefined ? resolved.title : `${sessionTitle} — ${resolved.title}`,
|
||||
))
|
||||
}
|
||||
updateTerminalTitle()
|
||||
|
||||
const requestRender = (): void => {
|
||||
footer.invalidate()
|
||||
@@ -1334,6 +1341,11 @@ export function createTuiChat(
|
||||
case 'todo/write':
|
||||
todo.update(event.data.todos)
|
||||
break
|
||||
case 'session/title':
|
||||
sessionTitle = event.data.title
|
||||
header.invalidate()
|
||||
updateTerminalTitle()
|
||||
break
|
||||
case 'turn/end':
|
||||
clearStreaming()
|
||||
if (event.data.reason.kind === 'error') {
|
||||
|
||||
@@ -7,6 +7,7 @@ import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@dee
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
@@ -165,6 +166,34 @@ describe('TUI config', () => {
|
||||
})
|
||||
|
||||
describe('pi-tui chat lifecycle and transcript', () => {
|
||||
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
|
||||
const result = await setup({
|
||||
beforeMount(session) {
|
||||
session.append('session/title', {
|
||||
title: 'Restored session title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.terminal.title).toBe('Restored session title — DeepSeek Harness')
|
||||
expect(result.terminal.output).toContain('Restored session title')
|
||||
expect(result.terminal.output).not.toContain('Coding agent ready.')
|
||||
|
||||
result.session.append('session/title', {
|
||||
title: 'Live title \u001B]0;unsafe\u0007',
|
||||
messageSeqs: [1, 5],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.title).toContain('Live title \\x1b]0;unsafe\\x07 — DeepSeek Harness')
|
||||
expect(result.terminal.title).not.toContain('\u001B')
|
||||
expect(result.terminal.output).toContain('Live title \\x1b]0;unsafe\\x07')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
|
||||
let now = 0
|
||||
const result = await setup({
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user