diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md new file mode 100644 index 000000000..9376a4537 --- /dev/null +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -0,0 +1,40 @@ +- In explicit prose routing, when a round calls `skip_reply` itself (in + native tool mode, and not from a `code_execution` script), the prose + gateway no longer delivers or bounces that round's plain text. + `skip_reply` promises that "Any plain text you wrote this turn stays + private and is NOT posted". The round's segments used to go through the + gateway like any other round's. Unprefixed text with no destination + bounced: it went onto the clipboard for an `{{unsent}}` resend, got a + bounce notice and could wake the resident again. Unprefixed text after a + destination set earlier in the turn was published there. Now none of that + round's envelopes is delivered or bounced, and none moves the destination + or asks for a continuation. A `>>skip_reply {{unsent}}` in it still sets + the retained text aside. A valid destination prefix, or a destination set + earlier in the turn, can still send the round's text through the live + outgoing stream as it is written. +- Explicit prose routing no longer streams unprefixed text to the trigger + channel. The MCPL outgoing stream (`channels/outgoing/chunk` and + `complete`) started with the trigger channel as its destination, so a + streaming-capable server there received text that delivery would bounce. + It now starts with no destination on a fresh turn, since explicit text has + none until a `>>` prefix names one. When a tool-result-guard retry or a + budget restart resumes the turn, it starts at the destination the turn's + delivered rounds have set, decided at each round's end, so a private + round's prefix doesn't count. The stream also reads a `>>` prefix after + any leading whitespace, and ends its target at any whitespace, as delivery + does, rather than streaming such a line as text or dropping it. The stream + parses each round's text from the start of a line, as delivery parses each + round on its own: a round that began with a `>>` prefix streamed it as + text to the previous round's destination. A round's later text block + starts a new line, as delivery joins a round's text blocks with a newline. + At each round's end, and when the membrane retries a refused attempt, the + stream returns to the turn's destination, so a private round's prefix, a + `>>skip_reply` envelope or a target that didn't resolve no longer moves + where later rounds stream. +- A turn resumed by a tool-result-guard retry or a budget restart now queues + its prose deliveries behind the turn's earlier ones, in every mode, so the + turn delivers in the order it wrote. Before, a resumed reply could be + delivered ahead of an earlier round's queued envelope, to the destination + that envelope was about to change. In explicit mode, a round's `>>` + targets are resolved once, when the round ends, and its queued delivery + posts to those channels. diff --git a/src/framework.ts b/src/framework.ts index f82e3e8df..bbe8b1706 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -481,6 +481,50 @@ function withDeferredWriteId(metadata: MessageMetadata | undefined, id: string): return { ...(metadata ?? {}), deferredWriteId: id } as MessageMetadata; } +/** + * A prose block's envelopes, as explicit mode routes them: every line whose + * text, after its leading whitespace, is `>>` and then a non-space opens a + * new envelope (first live use: Tilde 2026-07-24, one block carrying a + * letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope + * parser delivered the aside literally into the first destination — exactly + * the leak class this mode exists to kill). Text before the first `>>` is + * its own unprefixed envelope. A quoted `>> arrow` (space after) stays body + * text. Blank envelopes are dropped, and an envelope's whitespace before its + * `>>` is trimmed. + */ +function splitExplicitEnvelopes(rawText: string): string[] { + const envelopes: string[] = []; + let current: string[] = []; + for (const line of rawText.split('\n')) { + if (/^>>\S/.test(line.trimStart()) && current.length > 0) { + envelopes.push(current.join('\n')); + current = []; + } + current.push(line); + } + if (current.length > 0) envelopes.push(current.join('\n')); + return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, '')); +} + +/** + * An envelope of explicit prose as its round was admitted: for a `>>` target, + * the registry's answer then, which its delivery uses. + */ +interface AdmittedEnvelope { + text: string; + resolved?: { channelId: string } | { error: string; candidates?: string[] }; +} + +/** + * Whether a stream resumes its logical turn rather than starting one: a budget + * restart, or a tool-result-guard retry. Such a stream keeps the turn's + * routing state (the sticky destination, the turn's destination) and queues + * its deliveries behind the turn's earlier ones. + */ +function resumesLogicalTurn(reason: string | undefined): boolean { + return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry'; +} + function isTurnContinuation(reason: string): boolean { return reason === 'context_budget_restart' || reason === 'tool_results_ready' || reason === 'tool_result_guard_retry'; @@ -1080,9 +1124,23 @@ export class AgentFramework { // ---- Explicit prose routing (docs/explicit-prose-routing.md) ---------- /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */ private proseClipboards: Map = new Map(); - /** Sticky per-TURN delivery target set by the turn's first `>>` prefix. - * Cleared at every non-restart turn start; survives context restarts. */ + /** Sticky per-TURN delivery target, set by each `>>` prefix that resolves. + * Cleared at every fresh turn start, and on a silent wake; a stream that + * resumes the turn (resumesLogicalTurn) keeps it. */ private proseTargetPins: Map = new Map(); + /** + * Explicit mode: the destination the turn's delivered rounds have set, + * decided at each round boundary (roundDestination). A stream that resumes + * the turn (a tool-result-guard retry, a budget restart) starts there, + * however far the gateway's queued deliveries have got. Turn-scoped, like + * the sticky destination. + */ + private proseTurnDestinations: Map = new Map(); + /** + * The latest link of each agent's chain of queued prose deliveries, so a + * stream that resumes the turn queues behind the earlier streams' ones. + */ + private proseSpeechChains: Map> = new Map(); /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */ private proseHybridSuppressed: Set = new Set(); /** Agents whose current turn requested `!` continuation — re-woken when the @@ -8758,32 +8816,54 @@ export class AgentFramework { * nothing is ever sent to a destination the model did not name (directly * or via the turn's sticky target). */ - private async deliverProse(agent: Agent, rawText: string): Promise { - // Multi-envelope: a single prose block may address SEVERAL destinations — - // every line beginning with `>>` opens a new envelope, routed - // independently (first live use: Tilde 2026-07-24, one block carrying a - // letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope - // parser delivered the aside literally into the first destination — - // exactly the leak class this mode exists to kill). Text before the - // first `>>` is its own unprefixed envelope (sticky/bounce as usual). - const lines = rawText.split('\n'); - const envelopes: string[] = []; - let current: string[] = []; - for (const line of lines) { - // Only a REAL prefix line opens an envelope: `>>` immediately followed - // by a target token. A quoted `>> arrow` (space after) stays body text. - if (/^>>\S/.test(line.trimStart()) && current.length > 0) { - envelopes.push(current.join('\n')); - current = []; + private async deliverProse(agent: Agent, rawText: string, round?: { keepPrivate?: boolean }): Promise { + await this.deliverAdmittedProse(agent, this.admitProse(rawText), round); + } + + /** + * Explicit prose's envelopes, each `>>` target resolved now, once. A live + * round is admitted at its boundary: its turn's destination is decided from + * these resolutions (roundDestination), and its queued delivery posts to + * them, however the registry's names change before it runs. + */ + private admitProse(rawText: string): AdmittedEnvelope[] { + return splitExplicitEnvelopes(rawText).map((text) => { + const parsed = parseProsePrefix(text); + if (parsed.kind !== 'target') return { text }; + try { + return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!) }; + } catch (err) { + // Admission runs in the stream's event loop, so a resolver that + // throws makes the envelope bounce rather than stop the round. + return { text, resolved: { error: `the destination couldn't be resolved: ${err instanceof Error ? err.message : String(err)}` } }; } - current.push(line); + }); + } + + private async deliverAdmittedProse( + agent: Agent, + envelopes: AdmittedEnvelope[], + round?: { keepPrivate?: boolean }, + ): Promise { + // Multi-envelope: a single prose block may address SEVERAL destinations, + // each envelope routed independently (sticky/bounce as usual). + for (const envelope of envelopes) { + await this.deliverProseEnvelope(agent, envelope, round); } - if (current.length > 0) envelopes.push(current.join('\n')); + } + /** + * Where a delivered round leaves its turn's sticky destination, as the + * explicit gateway sets it once the round's envelopes run: each `>>` + * envelope whose target resolved becomes the destination. One that didn't + * resolve bounces and leaves it, as do `>>skip_reply` and unprefixed text. + */ + private roundDestination(current: string | null, envelopes: AdmittedEnvelope[]): string | null { + let destination = current; for (const envelope of envelopes) { - if (!envelope.trim()) continue; - await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, '')); + if (envelope.resolved && 'channelId' in envelope.resolved) destination = envelope.resolved.channelId; } + return destination; } /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */ @@ -8877,10 +8957,19 @@ export class AgentFramework { } } - private async deliverProseEnvelope(agent: Agent, rawText: string): Promise { + private async deliverProseEnvelope( + agent: Agent, + envelope: AdmittedEnvelope, + round?: { keepPrivate?: boolean }, + ): Promise { const name = agent.name; + const rawText = envelope.text; const parsed = parseProsePrefix(rawText); - if (parsed.continueTurn) this.proseContinuations.add(name); + // A private round (one that called skip_reply) asks for no continuation. + // Its turn ends at the round's tool results, and explicit mode doesn't + // await the speech chain there, so this delivery can run after the next + // turn has begun: a flag set here would reach that turn. + if (parsed.continueTurn && !round?.keepPrivate) this.proseContinuations.add(name); if (parsed.kind === 'private') { // The text already lives in the assistant message (window/chronicle); @@ -8891,10 +8980,25 @@ export class AgentFramework { return; } + if (round?.keepPrivate) { + // skip_reply's promise: "Any plain text you wrote this turn stays + // private and is NOT posted". Every envelope of the round stays in the + // context, as a >>skip_reply envelope does: it isn't sent, doesn't + // bounce to the clipboard and doesn't move the destination. + console.error( + `[prose] ${name}: ${parsed.kind === 'target' ? `>>${parsed.target}` : 'unprefixed'} — ` + + `${parsed.body.length} chars kept in context (skip_reply round)`, + ); + return; + } + let targetChannel: string; let body: string; if (parsed.kind === 'target') { - const res = this.channelRegistry!.resolveProseTarget(parsed.target!); + // Resolved once, when the round was admitted (admitProse), never again + // here after the wait: a `>>` envelope without its resolution is a bug. + const res = envelope.resolved; + if (!res) throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`); if ('error' in res) { this.bounceProse(agent, parsed.body, res.error, res.candidates); return; @@ -9135,8 +9239,7 @@ export class AgentFramework { turnToken: number, ownsProviderGate: boolean, ): Promise { - const continuingTurn = trigger?.reason === 'context_budget_restart' - || trigger?.reason === 'tool_result_guard_retry'; + const continuingTurn = resumesLogicalTurn(trigger?.reason); const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting; // Flush messages deferred during the PREVIOUS turn — before the // checkpoint, the locus announcement, and the compile — so a turn started @@ -9287,12 +9390,15 @@ export class AgentFramework { this.turnProseDeliveries.delete(agent.name); this.turnProseSuppressed.delete(agent.name); this.proseHybridSuppressed.delete(agent.name); - if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name); + // The sticky destination and the turn's destination are turn-scoped in + // every mode, so a turn whose mode changes before it resumes can't + // start from an earlier turn's. + this.proseTargetPins.delete(agent.name); + this.proseTurnDestinations.delete(agent.name); if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { // Explicit and disabled prose routing have no inferred locus. // Explicit mode uses a turn-scoped `>>` target; disabled mode has no // prose target at all. Neither mode freezes or announces a locus. - this.proseTargetPins.delete(agent.name); this.proseContinuations.delete(agent.name); this.midTurnInputSignals.delete(agent.name); this.turnLocusPins.delete(agent.name); @@ -9311,6 +9417,7 @@ export class AgentFramework { if (trigger?.suppressProse) { this.turnLocusPins.delete(agent.name); this.proseTargetPins.delete(agent.name); + this.proseTurnDestinations.delete(agent.name); this.proseContinuations.delete(agent.name); } @@ -9576,14 +9683,30 @@ export class AgentFramework { // awaits the chain before routing trailing prose, so in-channel ordering // is preserved end-to-end. Each link catches its own error: one failed // post must not silence the rest of the turn. - let turnSpeechChain: Promise = Promise.resolve(); + // + // A stream that resumes its turn (as beginAgentTurn's continuingTurn: a + // budget restart, a tool-result-guard retry) keeps the turn's routing + // state, and queues its deliveries behind the earlier streams' ones. So + // the turn's deliveries run in the order its text was written, and the + // sticky destination moves in that order too, whichever stream wrote the + // text and however long an earlier post takes. + const resumesTurn = resumesLogicalTurn(trigger?.reason); + let turnSpeechChain: Promise = resumesTurn + ? this.proseSpeechChains.get(agent.name) ?? Promise.resolve() + : Promise.resolve(); + // Kept at once, so a fresh turn that queues nothing still replaces the + // previous turn's chain for a stream that resumes it. + this.proseSpeechChains.set(agent.name, turnSpeechChain); + const chainSpeech = (link: () => Promise, failure: string): void => { + turnSpeechChain = turnSpeechChain.then(link).catch((err) => console.error(failure, err)); + // A stream another has replaced doesn't move the turn's latest link. + if (agent.streamId === myStreamId) this.proseSpeechChains.set(agent.name, turnSpeechChain); + }; const enqueueSpeech = (text: string, locus: string | null): void => { - turnSpeechChain = turnSpeechChain - .then(async () => { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus); - this.recordProseDelivery(agent.name, outcome); - }) - .catch((err) => console.error('mid-turn speech routing failed:', err)); + chainSpeech(async () => { + const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus); + this.recordProseDelivery(agent.name, outcome); + }, 'mid-turn speech routing failed:'); }; // Sticky explicit-send suppression: prose after send_message stays quiet @@ -9662,7 +9785,16 @@ export class AgentFramework { !agent.toolWrapperProseGuard ? new ProseStreamRouter({ mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus', - initialTarget: typingChannel, + // Explicit text has no destination until a `>>` prefix names one, + // so a fresh turn's stream starts suppressed. One that resumes the + // turn (a tool-result-guard retry, a budget restart) starts at the + // destination the turn's delivered rounds have set: a private + // round's prefixes don't count, and the gateway's queued + // deliveries needn't have run. The trigger channel is only where + // explicit mode's typing indicator shows. + initialTarget: turnProseRouting === 'explicit' + ? (resumesTurn ? this.proseTurnDestinations.get(agent.name) ?? null : null) + : typingChannel, resolve: (spec) => { const r = this.channelRegistry!.resolveProseTarget(spec); return 'channelId' in r ? r.channelId : null; @@ -9684,6 +9816,8 @@ export class AgentFramework { // other trace consumers voice it). Hold both; release in order on a clean // round (tool-calls / non-guard completion), discard on a guard refusal. let guardHeld: Array<{ trace: Parameters[0]; text?: string }> = []; + // The text block the stream's last text came from, within this round. + let lastTextBlock: number | undefined; const releaseGuardHeld = (): void => { const held = guardHeld; guardHeld = []; @@ -9728,7 +9862,17 @@ export class AgentFramework { // turn's prose is bound for, without re-deriving routing. channelId: typingChannel ?? undefined, }; - const text = event.meta.type === 'text' ? event.content : undefined; + // Delivery joins a round's text blocks with a newline + // (splitProseSegments), so a later block starts a new line for + // the stream too. + let text = event.meta.type === 'text' ? event.content : undefined; + if (text !== undefined) { + const block = event.meta.blockIndex; + if (block !== undefined) { + if (lastTextBlock !== undefined && block !== lastTextBlock) text = '\n' + text; + lastTextBlock = block; + } + } if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) { guardHeld.push({ trace, text }); } else { @@ -9761,6 +9905,11 @@ export class AgentFramework { outgoingIndex = 0; discardGuardHeld(); proseStream?.reset(); + lastTextBlock = undefined; + // Explicit mode's retried attempt starts where the turn's + // delivered rounds have set the destination, not at the stream's + // seed: rounds this stream delivered before the refusal stand. + if (turnProseRouting === 'explicit') proseStream?.retarget(this.proseTurnDestinations.get(agent.name) ?? null); this.emitTrace({ type: 'inference:tokens', agentName: agent.name, @@ -9790,6 +9939,10 @@ export class AgentFramework { // preceding results before storing/dispatching this new round. agent.toolResultGuard.accept(); releaseGuardHeld(); + // The round's text ends here: delivery parses each round's text + // on its own, so the next round's text starts a new line. + if (proseStream) emitOutgoing(proseStream.endSegment()); + lastTextBlock = undefined; adoptInjectedRound(); hadToolCalls = true; this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length); @@ -9905,25 +10058,43 @@ export class AgentFramework { } else if (!hasSameRoundPrivateThink) { const locus = resolveTurnLocus(); for (const seg of roundSegments) { - turnSpeechChain = turnSpeechChain - .then(() => this.deliverHybridProse(agent, seg, locus, true)) - .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err)); + chainSpeech(() => this.deliverHybridProse(agent, seg, locus, true), 'mid-turn hybrid prose delivery failed:'); } } } else if (turnProseRouting === 'explicit') { - // Explicit mode: every segment through the prose gateway. - // Silencing does not apply — unprefixed prose bounces and - // prefixed prose is deliberate; think-privacy still holds. + // Explicit mode: the segments go through the prose + // gateway, unless a private think in the round holds them + // back. Send silencing does not apply: unprefixed prose + // bounces, and prefixed prose is deliberate. skip_reply + // does: its round's envelopes all stay in the context + // (deliverProseEnvelope's keepPrivate), as the tool + // promises, and skip_reply then ends the turn at this + // round's tool results. if (!hasSameRoundPrivateThink) { + const keepPrivate = roundToolNames.some((n) => bareToolName(n) === 'skip_reply'); + // The round is admitted now: its targets are resolved + // once, for the turn's destination and for its queued + // delivery alike. + const admitted = roundSegments.map((seg) => this.admitProse(seg)); + if (!keepPrivate) { + this.proseTurnDestinations.set( + agent.name, + this.roundDestination(this.proseTurnDestinations.get(agent.name) ?? null, admitted.flat()), + ); + } console.error( - `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`, + `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`, ); - for (const seg of roundSegments) { - turnSpeechChain = turnSpeechChain - .then(() => this.deliverProse(agent, seg)) - .catch((err) => console.error('mid-turn prose delivery failed:', err)); + for (const envelopes of admitted) { + chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { keepPrivate }), 'mid-turn prose delivery failed:'); } } + // The stream's next round starts where delivery sends + // unprefixed text: the turn's destination. The stream + // followed this round's prefixes as it was written, but a + // private round's, a >>skip_reply envelope's or a target + // that didn't resolve leave the destination as it was. + proseStream?.retarget(this.proseTurnDestinations.get(agent.name) ?? null); } else if (turnSilenced) { console.error( `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`, @@ -9972,6 +10143,7 @@ export class AgentFramework { // persisted as assistant speech, including a terminal refusal // after the single recovery retry. proseStream?.reset(); + if (turnProseRouting === 'explicit') proseStream?.retarget(this.proseTurnDestinations.get(agent.name) ?? null); if (withheld) { const usage = response.details?.usage ?? response.usage; const tokenUsage = usage ? { @@ -10152,6 +10324,17 @@ export class AgentFramework { const speechContent = hadToolCalls ? [] : allText; const thoughts = hadToolCalls ? allText : []; + // Explicit text is admitted as its round ends, before anything + // below waits (a module's speech callback, the turn's earlier + // deliveries): its targets resolve now, as a live round's do. + const finalSpeechText = speechContent.map((b) => b.text).join('\n').trim(); + const finalSegments = hadToolCalls && allText.length > 0 + ? splitProseSegments(liveProseRouting ? terminalContent : response.content) + : []; + const admitFinal = turnProseRouting === 'explicit' && !!this.channelRegistry; + const admittedSpeech = admitFinal && finalSpeechText ? this.admitProse(finalSpeechText) : null; + const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : []; + const du = response.details?.usage; const tokenUsage = du ? { @@ -10364,11 +10547,11 @@ export class AgentFramework { // they are never routed here — which is precisely how the `think` // tool (and any explicit send tool) yields a silent turn. if (speechContent.length > 0 && this.channelRegistry) { - const speechText = speechContent - .map((b) => (b as ContentBlock & { type: 'text' }).text) - .join('\n') - .trim(); + const speechText = finalSpeechText; if (speechText) { + // A turn resumed after its earlier rounds delivers after + // them: their queued deliveries are this chain's. + await turnSpeechChain; if (turnProseRouting === 'disabled') { console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`); this.recordProseSuppression(agent.name, 1); @@ -10390,7 +10573,7 @@ export class AgentFramework { } else if (turnProseRouting === 'explicit') { console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`); try { - await this.deliverProse(agent, speechText); + await this.deliverAdmittedProse(agent, admittedSpeech!); } catch (err) { console.error('text-only prose delivery failed:', err); } @@ -10436,7 +10619,7 @@ export class AgentFramework { ? turnSilenced : turnSilenced || toolNames.some(isSilencingTool); - const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content); + const segments = finalSegments; // Preserve in-channel ordering: everything enqueued live must // land before the trailing prose. Awaited even when silenced — @@ -10468,9 +10651,9 @@ export class AgentFramework { console.error( `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`, ); - for (const seg of segments) { + for (const envelopes of admittedSegments) { try { - await this.deliverProse(agent, seg); + await this.deliverAdmittedProse(agent, envelopes); } catch (err) { console.error('trailing prose delivery failed:', err); } diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index e14ad8c3b..1814b9071 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -19,8 +19,13 @@ * emits NOTHING — the delivery path will bounce/retain it; a stream consumer * must never see text that delivery would refuse to send. * - * Two modes, mirroring proseRouting: - * - explicit: parse `>>` prefixes; initial target null (suppressed). + * Three modes, mirroring proseRouting: + * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed; + * one that resumes a turn starts at the turn's destination, and + * each round's end returns the stream to it (retarget). + * - hybrid: parse `>>>` envelopes, with the frozen turn locus as the + * fallback. An envelope with no target, or `>>>skip_reply`, + * holds back what follows until a later `>>>` names a target. * - locus: NO prefix parsing (locus mode's `>>` lines are plain text); * everything streams to the frozen turn locus. */ @@ -30,7 +35,10 @@ export interface RoutedDelta { delta: string; } -type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body'; +type State = 'line-start' | 'indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body'; + +/** Whitespace as the delivery path reads it (`trimStart()`, `\s`, `\S`). */ +const isSpace = (ch: string): boolean => /\s/.test(ch); export class ProseStreamRouter { private target: string | null; @@ -44,7 +52,9 @@ export class ProseStreamRouter { private opts: { /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */ mode: 'explicit' | 'hybrid' | 'locus'; - /** Initial destination: the frozen turn locus (locus mode) or null. */ + /** Initial destination: the frozen turn locus (locus and hybrid mode); in + * explicit mode, null on a fresh turn, or the turn's destination on a + * stream that resumes one. */ initialTarget: string | null; /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */ resolve: (spec: string) => string | null; @@ -59,6 +69,17 @@ export class ProseStreamRouter { return this.accumulated; } + /** + * Point the stream at a destination decided outside it. Explicit mode does + * this at each round's end, with the turn's destination: a private round's + * prefixes, a `>>skip_reply` envelope and a target that didn't resolve all + * leave that destination as it was, though the stream followed them while + * the round was written. + */ + retarget(target: string | null): void { + this.target = target; + } + /** * Drop everything routed so far and return to the constructed state. * @@ -82,23 +103,38 @@ export class ProseStreamRouter { return coalesce(out); } - /** End of stream: flush any held line-start text (it can no longer become a - * complete prefix; an unterminated `>>tok` at EOF is routing metadata with - * no body — dropped, matching delivery which would deliver empty body). */ - finish(): RoutedDelta[] { + /** + * The end of a segment: one round's text, which delivery parses on its + * own. The pending line's decision is made now. Held line-start text can + * no longer become a prefix, so it is body. A prefix whose target is + * written but has no body takes effect, as delivery reads `>>#chan` with + * nothing after it. The next segment starts a new line. + */ + endSegment(): RoutedDelta[] { const out: RoutedDelta[] = []; - if (this.state === 'hybrid-indent') this.emit(this.held, out); + if (this.state === 'indent') this.emit(this.held, out); if (this.state === 'maybe-prefix') { - const full = this.opts.mode === 'hybrid' ? '>>>' : '>>'; - const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held; - if (arrows !== full) this.emit(this.held, out); + // Hybrid's `>>>` with nothing after it is an envelope with no target: + // delivery bounces it and holds back what follows. Anything else held + // is text to delivery, explicit's bare `>>` included. + const arrows = this.held.trimStart(); + if (this.opts.mode === 'hybrid' && arrows === '>>>') this.target = null; + else this.emit(this.held, out); } - // prefix-token / after-token at EOF: prefix without body — nothing to emit. + // Hybrid's `>>>` and a gap with nothing after it: no target either. + if (this.state === 'prefix-gap') this.target = null; + if (this.state === 'prefix-token') this.applyTarget(); this.held = ''; - this.state = 'line-start'; + this.token = ''; + if (this.opts.mode !== 'locus') this.state = 'line-start'; return coalesce(out); } + /** End of stream: the last segment ends. */ + finish(): RoutedDelta[] { + return this.endSegment(); + } + private step(ch: string, out: RoutedDelta[]): void { switch (this.state) { case 'body': @@ -111,8 +147,11 @@ export class ProseStreamRouter { return; case 'line-start': - if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) { - this.state = 'hybrid-indent'; + // Indentation before a prefix is held until the line's decision: the + // delivery path reads an indented `>>` (explicit) or `>>>` (hybrid) + // line as a prefix too, after any leading whitespace. + if (ch !== '\n' && isSpace(ch)) { + this.state = 'indent'; this.held = ch; return; } @@ -121,8 +160,8 @@ export class ProseStreamRouter { this.step(ch, out); return; - case 'hybrid-indent': - if (ch === ' ' || ch === '\t') { this.held += ch; return; } + case 'indent': + if (ch !== '\n' && isSpace(ch)) { this.held += ch; return; } if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; } this.emit(this.held, out); this.held = ''; @@ -132,7 +171,7 @@ export class ProseStreamRouter { case 'maybe-prefix': { const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>'; - const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held; + const arrows = this.held.trimStart(); if (ch === '>' && arrows.length < prefix.length) { this.held += '>'; return; @@ -143,11 +182,22 @@ export class ProseStreamRouter { this.state = 'prefix-gap'; return; } - if (ch === '\n' || ch === ' ' || ch === '\t') { - if (this.opts.mode === 'explicit') this.emit(this.held, out); + if (isSpace(ch)) { + if (this.opts.mode === 'explicit') { + // `>>` and then whitespace is a quoted arrow: body text, since + // delivery's prefix needs a target right after the arrows. + this.emit(this.held, out); + } else { + // `>>>` and then whitespace other than a space or tab (a + // newline included) is an envelope with no target. Delivery + // bounces it and holds back what follows until a later `>>>` + // names a target. + this.target = null; + } + // The whitespace itself is body, a newline included. this.held = ''; - this.state = ch === '\n' ? 'line-start' : 'body'; - if (ch !== '\n') this.step(ch, out); + this.state = 'body'; + this.step(ch, out); return; } this.state = 'prefix-token'; @@ -165,6 +215,9 @@ export class ProseStreamRouter { case 'prefix-gap': if (ch === ' ' || ch === '\t') return; if (ch === '\n') { this.target = null; this.state = 'line-start'; return; } + // Other whitespace can't start hybrid's target (`[ \t]*\S+`): an + // envelope with no target, as above. + if (isSpace(ch)) { this.target = null; this.state = 'body'; return; } this.state = 'prefix-token'; this.token = ch; return; @@ -172,6 +225,9 @@ export class ProseStreamRouter { case 'prefix-token': if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; } if (ch === '\n') { this.applyTarget(); this.state = 'line-start'; return; } + // Any other whitespace ends the token too (`\S+`), and starts the + // body: the grammar's gap after a token is spaces and tabs only. + if (isSpace(ch)) { this.applyTarget(); this.state = 'body'; this.step(ch, out); return; } this.token += ch; return; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 98a000067..a7cc06210 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -22,9 +22,11 @@ import type { ToolDefinition, ToolCall, ToolResult, + InferenceRequest, } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { ProseStreamRouter } from '../src/mcpl/prose-stream-router.js'; import type { ContentBlock } from '@animalabs/membrane'; class ToolboxModule implements Module { @@ -225,6 +227,340 @@ describe('explicit prose routing', () => { await framework.stop(); }); + /** The registry stub answers skip_reply as the real registry does: the turn ends, and nothing is sent. */ + function answerSkipReply(framework: AgentFramework): { calls: number } { + const answered = { calls: 0 }; + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.handleChannelToolCall = async (name: string) => { + if (name === 'skip_reply') answered.calls++; + return { success: true, endTurn: true, data: { skipped: true, note: 'Turn ended; nothing sent.' } }; + }; + return answered; + } + + /** What the registry stub's streaming surface receives (MCPL Spec 14.3 chunk and complete). */ + function captureStream(framework: AgentFramework) { + const chunks: Array<{ channelId: string; delta: string }> = []; + const completes: Array<{ channelId: string; text: string }> = []; + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.sendOutgoingChunk = (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + chunks.push({ channelId, delta }); + }; + registry.sendOutgoingComplete = (channelId: string, _agent: string, _id: string, text: string) => { + completes.push({ channelId, text }); + }; + return { chunks, completes }; + } + + /** The texts of the agent's context, and the bounce notices among them. */ + function context(framework: AgentFramework): { texts: string[]; bounces: number } { + const messages = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ + content: Array<{ type: string; text?: string }>; + metadata?: { kind?: string }; + }>; + return { + texts: messages.flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => b.text ?? '')), + bounces: messages.filter((m) => m.metadata?.kind === 'prose-bounce').length, + }; + } + + const clipboardOf = (framework: AgentFramework): string | undefined => + (framework as unknown as { proseClipboards: Map }).proseClipboards.get('assistant'); + + it('a round that calls skip_reply keeps its unprefixed prose private: nothing delivered, bounced, retained or woken for', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Weighing whether this needs an answer from me.' }, + { type: 'tool_use', id: 'sr1', name: 'skip_reply', input: { reason: 'nothing to add' } }, + ] as ContentBlock[], 'tool_use')); + + const framework = await createFramework(); + const routed = stubRegistry(framework); + const skip = answerSkipReply(framework); + // Were the text bounced, its wake would start a second turn: give that + // turn a response, so the test fails on its count instead of hanging. + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + + let turns = 0; + framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started') turns++; + }); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(skip.calls, 1, 'the skip_reply round ran'); + assert.equal(routed.length, 0, 'nothing is delivered'); + const seen = context(framework); + assert.equal(seen.bounces, 0, 'no bounce notice reaches the context'); + assert.ok(seen.texts.some((t) => t.includes('Weighing whether this needs an answer')), 'the text stays in the context'); + assert.equal(clipboardOf(framework), undefined, 'nothing is retained for {{unsent}}'); + assert.equal(turns, 1, 'no bounce wake starts another turn'); + + await framework.stop(); + }); + + it('a round that calls skip_reply keeps its unprefixed prose from a sticky destination', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha first, deliberately' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Second thoughts I keep to myself.' }, + { type: 'tool_use', id: 'sr2', name: 'skip_reply', input: {} }, + ] as ContentBlock[], 'tool_use')); + + const framework = await createFramework(); + const routed = stubRegistry(framework); + const skip = answerSkipReply(framework); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(skip.calls, 1, 'the skip_reply round ran'); + assert.deepEqual(routed, [{ text: 'first, deliberately', locus: 'chan-alpha' }], + 'only the earlier, deliberately addressed segment goes out'); + assert.ok(context(framework).texts.some((t) => t.includes('Second thoughts I keep to myself.')), 'the text stays in the context'); + + await framework.stop(); + }); + + it('a round that calls skip_reply sends nothing, a prefixed segment included, and asks for no continuation', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha ! addressed, then thought better of' }, + { type: 'tool_use', id: 'sr3', name: 'skip_reply', input: {} }, + ] as ContentBlock[], 'tool_use')); + + const framework = await createFramework(); + const routed = stubRegistry(framework); + const skip = answerSkipReply(framework); + // A continuation would start a second turn: give it a response, so the + // test fails on its count instead of hanging. + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + let turns = 0; + framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started') turns++; + }); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(skip.calls, 1, 'the skip_reply round ran'); + assert.equal(routed.length, 0, 'skip_reply ends the turn without sending anything'); + assert.equal(context(framework).bounces, 0); + assert.equal(turns, 1, "the round's ' !' starts no continuation after skip_reply ended the turn"); + + await framework.stop(); + }); + + it("a private round's ' !' leaves no continuation request behind", async () => { + const framework = await createFramework(); + const routed = stubRegistry(framework); + const internals = framework as unknown as { + deliverProse(agent: unknown, text: string, round?: { keepPrivate?: boolean }): Promise; + proseContinuations: Set; + }; + const agent = framework.getAgent('assistant')!; + + // When skip_reply ends an explicit turn, the speech chain isn't awaited, + // so the round's delivery can run once the next turn has begun. + await internals.deliverProse(agent, '>>#alpha ! kept private', { keepPrivate: true }); + assert.equal(routed.length, 0, 'nothing is delivered'); + assert.equal(internals.proseContinuations.has('assistant'), false, 'and no continuation is asked for'); + + // The same envelope outside a private round asks for one. + await internals.deliverProse(agent, '>>#alpha ! sent'); + assert.equal(routed.length, 1); + assert.equal(internals.proseContinuations.has('assistant'), true); + + await framework.stop(); + }); + + it('a skip_reply round that sets the retained text aside with >>skip_reply {{unsent}} clears it', async () => { + // Turn 1 bounces its text, which retains it and wakes turn 2. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'An answer with nowhere to go.' }] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubRegistry(framework); + const skip = answerSkipReply(framework); + feedTurn(framework, 2, [ + { type: 'text', text: '>>skip_reply {{unsent}}' }, + { type: 'tool_use', id: 'sr4', name: 'skip_reply', input: {} }, + ] as ContentBlock[], 'tool_use'); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(skip.calls, 1, 'the bounce wake ran the skip_reply round'); + assert.equal(routed.length, 0); + assert.equal(clipboardOf(framework), undefined, 'the retained text is set aside, as >>skip_reply {{unsent}} says'); + + await framework.stop(); + }); + + it('explicit mode streams nothing before a destination: unprefixed text never reaches the trigger channel', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Unprefixed musing.\n>>#alpha streamed after the prefix' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubRegistry(framework); + const stream = captureStream(framework); + // The unprefixed line bounces, and its wake starts a second turn. + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + + // A turn the trigger channel woke: chan-alpha is where its typing shows. + const internals = framework as unknown as { + pendingRequests: InferenceRequest[]; + processInferenceRequests(): Promise; + }; + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), + channelId: 'chan-alpha', addressed: true, + }); + await internals.processInferenceRequests(); + await framework.runUntilIdle(); + + const streamed = stream.chunks.map((c) => c.delta).join(''); + assert.ok(streamed.includes('streamed after the prefix'), 'prefixed text streams to its destination'); + assert.equal(streamed.includes('Unprefixed musing.'), false, 'unprefixed text is never streamed'); + assert.equal(stream.completes.some((c) => c.text.includes('Unprefixed musing.')), false, + 'nor finalized by the stream complete'); + + await framework.stop(); + }); + + it('the stream reads leading whitespace and a token\'s end as delivery does', async () => { + const framework = await createFramework(); + const routed = stubRegistry(framework); + const gateway = framework as unknown as { deliverProse(agent: unknown, text: string): Promise }; + const agent = framework.getAgent('assistant')!; + const cases = [' >>#alpha hello', '\u00a0>>#alpha hello', '\u2003>>#alpha hello', '\r>>#alpha hello', '\t>>#alpha\r\nhello']; + try { + for (const text of cases) { + routed.length = 0; + await gateway.deliverProse(agent, text); + const router = new ProseStreamRouter({ + mode: 'explicit', initialTarget: null, resolve: (spec) => (spec === '#alpha' ? 'chan-alpha' : null), + }); + const streamed = [...router.feed(text), ...router.finish()]; + assert.deepEqual(routed.map((r) => r.locus), ['chan-alpha'], `delivery sends ${JSON.stringify(text)} to #alpha`); + assert.ok(streamed.length > 0 && streamed.every((d) => d.channelId === 'chan-alpha'), + `the stream sends ${JSON.stringify(text)} there too`); + } + } finally { + await framework.stop(); + } + }); + + it("a round's destination, decided at its boundary, is the sticky destination its delivery leaves", async () => { + const framework = await createFramework(); + stubRegistry(framework); + const internals = framework as unknown as { + deliverProse(agent: unknown, text: string): Promise; + admitProse(text: string): unknown[]; + roundDestination(current: string | null, envelopes: unknown[]): string | null; + proseTargetPins: Map; + }; + const agent = framework.getAgent('assistant')!; + const rounds: string[][] = [ + ['>>#alpha first\n>>#alpha'], + [' >>#alpha indented'], + ['>>#nowhere bounced'], + ['>>skip_reply kept\nand unprefixed'], + ['unprefixed', '>>#alpha second segment'], + ]; + try { + for (const segments of rounds) { + for (const start of [null, 'chan-alpha']) { + internals.proseTargetPins.clear(); + if (start) internals.proseTargetPins.set('assistant', start); + for (const segment of segments) await internals.deliverProse(agent, segment); + const admitted = segments.flatMap((segment) => internals.admitProse(segment)); + assert.equal(internals.roundDestination(start, admitted), internals.proseTargetPins.get('assistant') ?? null, + `${JSON.stringify(segments)} from ${start}`); + } + } + } finally { + await framework.stop(); + } + }); + + it('hybrid: a `>>>` envelope with no target streams nothing, as delivery bounces it', async () => { + const framework = await createFramework('hybrid'); + const routed = stubRegistry(framework); + const gateway = framework as unknown as { + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean): Promise; + }; + const agent = framework.getAgent('assistant')!; + // The registry's resolver trims its argument, as the router's must here. + const resolve = (spec: string) => (spec.trim() === '#alpha' ? 'chan-alpha' : null); + const cases = ['>>>\r\nsecret', '>>>\nsecret', '>>>\u00a0#alpha secret', '>>> \u00a0#alpha secret']; + try { + for (const text of cases) { + routed.length = 0; + await gateway.deliverHybridProse(agent, text, 'chan-locus', true); + const router = new ProseStreamRouter({ mode: 'hybrid', initialTarget: 'chan-locus', resolve }); + const streamed = [...router.feed(text), ...router.finish()]; + assert.equal(routed.length, 0, `delivery bounces ${JSON.stringify(text)}`); + assert.deepEqual(streamed, [], `and the stream sends none of ${JSON.stringify(text)}`); + } + } finally { + await framework.stop(); + } + }); + + it('hybrid: a segment that is only `>>>`, or `>>>` and a gap, holds back the next segment, as delivery does', async () => { + const framework = await createFramework('hybrid'); + const routed = stubRegistry(framework); + const gateway = framework as unknown as { + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean): Promise; + }; + const agent = framework.getAgent('assistant')!; + const resolve = (spec: string) => (spec.trim() === '#alpha' ? 'chan-alpha' : null); + try { + for (const prefix of ['>>>', '>>> ', '>>>\t']) { + routed.length = 0; + await gateway.deliverHybridProse(agent, '>>>#alpha first', 'chan-locus', true); + await gateway.deliverHybridProse(agent, prefix, 'chan-locus', true); + await gateway.deliverHybridProse(agent, 'secret', 'chan-locus', true); + const router = new ProseStreamRouter({ mode: 'hybrid', initialTarget: 'chan-locus', resolve }); + const streamed = [ + ...router.feed('>>>#alpha first'), ...router.endSegment(), + ...router.feed(prefix), ...router.endSegment(), + ...router.feed('secret'), ...router.finish(), + ]; + assert.deepEqual(routed.map((r) => r.text), ['first'], `delivery sends only first, after ${JSON.stringify(prefix)}`); + assert.equal(streamed.filter((d) => d.delta.includes('secret')).length, 0, + `and the stream holds secret back after ${JSON.stringify(prefix)}`); + } + } finally { + await framework.stop(); + } + }); + + it('admission turns a resolver that throws into an envelope that bounces', async () => { + const framework = await createFramework(); + const routed = stubRegistry(framework); + const internals = framework as unknown as { + channelRegistry: { resolveProseTarget: (spec: string) => unknown }; + admitProse(text: string): Array<{ text: string; resolved?: { channelId?: string; error?: string } }>; + deliverAdmittedProse(agent: unknown, envelopes: unknown[]): Promise; + }; + const resolve = internals.channelRegistry.resolveProseTarget.bind(internals.channelRegistry); + internals.channelRegistry.resolveProseTarget = (spec: string) => { + if (spec === '#boom') throw new Error('directory unavailable'); + return resolve(spec); + }; + try { + const admitted = internals.admitProse('>>#boom one\n>>#alpha two'); + assert.match(admitted[0]!.resolved?.error ?? '', /directory unavailable/); + await internals.deliverAdmittedProse(framework.getAgent('assistant')!, admitted); + assert.deepEqual(routed.map((r) => r.text), ['two'], 'the other envelope still goes out'); + } finally { + await framework.stop(); + } + }); + it("' !' continuation starts another turn immediately after prose ends the current one", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha ! step one done, continuing' }, diff --git a/test/prose-stream-router.test.ts b/test/prose-stream-router.test.ts index fa177c918..8875ee237 100644 --- a/test/prose-stream-router.test.ts +++ b/test/prose-stream-router.test.ts @@ -67,6 +67,54 @@ test('unresolvable target suppresses that envelope only', () => { assert.equal(textFor(out, 'discord:g:100'), 'to somewhere'); }); +test('explicit: an indented prefix routes, as delivery reads it', () => { + const out = run(explicit(), ' >>#general indented reply\n\t>>#ops tabbed aside\n plain indented line'); + assert.equal(textFor(out, 'discord:g:100'), 'indented reply\n'); + assert.equal(textFor(out, 'discord:g:200'), 'tabbed aside\n plain indented line'); +}); + +test('explicit: indentation before a quoted arrow or a lone `>` stays body', () => { + const out = run(explicit(), '>>#general look:\n >> quoted arrow\n > quote'); + assert.equal(textFor(out, 'discord:g:100'), 'look:\n >> quoted arrow\n > quote'); +}); + +test('explicit: any leading whitespace before a prefix, as delivery trims it', () => { + const out = run(explicit(), '\u00a0>>#general nbsp\n\u2003>>#ops em space\n\r>>@sol after a CR'); + assert.equal(textFor(out, 'discord:g:100'), 'nbsp\n'); + assert.equal(textFor(out, 'discord:g:200'), 'em space\n'); + assert.equal(textFor(out, 'discord:dm:300'), 'after a CR'); +}); + +test('explicit: whitespace other than a space or tab ends the token and starts the body', () => { + const out = run(explicit(), '\t>>#ops\r\nhello'); + assert.equal(textFor(out, 'discord:g:200'), '\r\nhello'); +}); + +test('explicit: `>>` and then any whitespace is a quoted arrow', () => { + const out = run(explicit(), '>>#general look:\n>>\u00a0quoted'); + assert.equal(textFor(out, 'discord:g:100'), 'look:\n>>\u00a0quoted'); +}); + +test('explicit: a bare `>>` line is text, its newline included, as delivery sends it', () => { + const r = new ProseStreamRouter({ mode: 'explicit', initialTarget: 'discord:g:100', resolve: (spec) => RESOLVE[spec] ?? null }); + const out = [...r.feed('>>\nhello'), ...r.finish()]; + assert.equal(textFor(out, 'discord:g:100'), '>>\nhello'); +}); + +test('explicit: each segment starts a new line, and a prefix with no body takes effect at its end', () => { + // Delivery parses each round's text on its own: a round may begin with a + // prefix, and a round that is only a prefix sets the sticky destination. + const r = explicit(); + const out: RoutedDelta[] = []; + out.push(...r.feed('>>#general first'), ...r.endSegment()); + out.push(...r.feed('>>#ops second'), ...r.endSegment()); + out.push(...r.feed('>>@sol'), ...r.endSegment()); + out.push(...r.feed('to sol'), ...r.finish()); + assert.equal(textFor(out, 'discord:g:100'), 'first'); + assert.equal(textFor(out, 'discord:g:200'), 'second'); + assert.equal(textFor(out, 'discord:dm:300'), 'to sol'); +}); + test('` !` continuation modifier is swallowed; `!` as body survives', () => { const out = run(explicit(), '>>#general ! next turn continues\n>>#ops !bang body'); assert.equal(textFor(out, 'discord:g:100'), 'next turn continues\n'); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 71ad0787a..a9599a66e 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -346,6 +346,575 @@ test('native Membrane observes the first refusal even when guard is enabled by a } finally { await framework.stop(); } }); +test('explicit mode: a guard retry streams on to the sticky destination delivery uses', async () => { + const h = await harness([ + [createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), refused()], + [answer()], + ], { toolResultGuard: true, proseRouting: 'explicit', refusalHandling: { retries: 3 } }); + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, speech: string, channelId: string) => { + routed.push({ text: speech, channelId }); return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + await h.run(); + assert.equal(h.membrane.requests.length, 2, 'the guard retried once'); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'continued', channelId: 'chan-alpha' }, + ], 'delivery keeps the sticky destination across the retry'); + const toAlpha = streamed.filter((d) => d.channelId === 'chan-alpha').map((d) => d.delta).join(''); + assert.match(toAlpha, /continued/, 'the resumed stream follows delivery there'); + assert.doesNotMatch(streamed.map((d) => d.delta).join(''), /discard-this-partial-output/); + } finally { await h.framework.stop(); } +}); + +test('explicit mode: a budget restart streams on to the destination the turn last addressed, before delivery catches up', { timeout: 15000 }, async () => { + const h = await harness([ + [ + createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([ + { type: 'text', text: '>>#cafe second' }, + { type: 'tool_use', id: 'two', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + ], + [answer()], + ], { proseRouting: 'explicit', maxStreamTokens: 1000 }); + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + // Delivery of 'first' is held until the resumed stream has written + // 'continued', so '>>#cafe second' is still queued, and delivery's sticky + // destination still #alpha, when the restart begins. + let releaseFirst!: () => void; + const firstHeld = new Promise((resolve) => { releaseFirst = resolve; }); + let secondDelivered!: () => void; + const secondDone = new Promise((resolve) => { secondDelivered = resolve; }); + // The second tool call drops the stream budget, so the turn restarts. + const handle = h.module.handleToolCall.bind(h.module); + h.module.handleToolCall = async (call: ToolCall) => { + if (call.id === 'two') h.framework.getAgent('assistant')!.maxStreamTokens = 1; + return handle(call); + }; + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + if (h.membrane.requests.length === 2) { + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + for await (const event of events) { + yield event; + if (event.type === 'tokens' && event.content === 'continued') { + releaseFirst(); + await secondDone; + } + } + }; + } + return stream; + }; + const restarts: string[] = []; + h.framework.onTrace((event) => { + if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); + }); + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text, channelId }); + if (text === 'first') await firstHeld; + if (text === 'second') secondDelivered(); + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + await h.run(); + assert.equal(h.membrane.requests.length, 2); + assert.ok(restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'second', channelId: 'chan-cafe' }, + { text: 'continued', channelId: 'chan-cafe' }, + ]); + assert.deepEqual(streamed.filter((d) => d.delta.includes('second')).map((d) => d.channelId), ['chan-cafe'], + "the second round's prefix starts a new line in the stream, as delivery reads it"); + assert.equal(streamed.some((d) => d.delta.includes('>>')), false, 'no prefix is streamed as text'); + assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe'], + 'the resumed stream follows the destination the turn last addressed'); + } finally { + releaseFirst(); + secondDelivered(); + await h.framework.stop(); + } +}); + +test("explicit mode: a private round's prefix doesn't move where a budget restart resumes", { timeout: 15000 }, async () => { + // A same-round private think keeps its round's text out of delivery, so + // its `>>#cafe` sets no destination: the resumed reply goes to #alpha. + const privateRound = createMockResponse([ + { type: 'text', text: '>>#cafe private plan' }, + { type: 'tool_use', id: 'two', name: 'think', input: { content: 'plan privately' } }, + ] as ContentBlock[], 'tool_use'); + privateRound.usage = { inputTokens: 100, outputTokens: 5 }; + const h = await harness([ + [ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + privateRound, + ], + [answer()], + ], { proseRouting: 'explicit', maxStreamTokens: 20, sameRoundThinkTextPolicy: 'private' }); + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + const restarts: string[] = []; + h.framework.onTrace((event) => { + if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); + }); + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text, channelId }); + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + handleChannelToolCall: async () => ({ success: true, data: { noted: true } }), + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, + getChannelTools: () => [{ name: 'think', description: 'Reason privately', inputSchema: { type: 'object', properties: { content: { type: 'string' } } } }], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + await h.run(); + assert.equal(h.membrane.requests.length, 2); + assert.ok(restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'continued', channelId: 'chan-alpha' }, + ], 'the private round moves no destination'); + assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-alpha'], + 'the resumed reply streams where it is delivered'); + } finally { + await h.framework.stop(); + } +}); + +/** + * A budget-restart turn whose first round's delivery is held: round one + * writes `first`, round two `second` and crosses the budget, and the resumed + * stream writes `continued`. `held` resolves once the resumed stream has + * handed over its 'complete' and either its reply has gone out or the event + * loop has turned 200 times, enough for a delivery that doesn't wait on the + * held one to run; `release` lets the held delivery finish. + */ +async function heldRestart(mode: 'explicit' | 'locus', texts: { first: string; second: string; continued: string }) { + const secondRound = createMockResponse([ + { type: 'text', text: texts.second }, + { type: 'tool_use', id: 'two', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'); + secondRound.usage = { inputTokens: 100, outputTokens: 5 }; + const h = await harness([ + [ + createMockResponse([ + { type: 'text', text: texts.first }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + secondRound, + ], + [createMockResponse([{ type: 'text', text: texts.continued }] as ContentBlock[])], + ], { proseRouting: mode, maxStreamTokens: 20 }); + const routed: Array<{ text: string; channelId: string | null }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + // What the registry's #cafe names; a test may rebind it while 'first' is held. + const directory = { cafe: 'chan-cafe' }; + let release!: () => void; + const firstHeld = new Promise((resolve) => { release = resolve; }); + let resumedEnded!: () => void; + const ended = new Promise((resolve) => { resumedEnded = resolve; }); + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + if (h.membrane.requests.length === 2) { + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + // The framework stops pulling once it has 'complete', so the signal + // goes with that event rather than after the loop. + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string }>; + for await (const event of events) { + if (event.type === 'complete') resumedEnded(); + yield event; + } + }; + } + return stream; + }; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: directory.cafe } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string | null) => { + routed.push({ text: text.trim(), channelId }); + if (text.trim() === 'first') await firstHeld; + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + const restarts: string[] = []; + h.framework.onTrace((event) => { + if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); + }); + const run = h.run(); + const held = ended.then(async () => { + for (let turn = 0; turn < 200 && !routed.some((r) => r.text === texts.continued.replace(/^>>\S+\s*/, '').trim()); turn++) { + await new Promise((resolve) => setImmediate(resolve)); + } + }); + return { h, run, held, release, routed, streamed, restarts, directory }; +} + +test("explicit mode: a resumed stream's deliveries queue behind the turn's earlier ones", { timeout: 15000 }, async () => { + // From a review's probe. While 'first' is still posting, '>>#cafe second' + // is queued behind it, and the resumed reply waits behind both: it then + // goes to #cafe, where the turn last addressed, as the stream sends it. + const t = await heldRestart('explicit', { first: '>>#alpha first\n', second: '>>#cafe second', continued: 'continued' }); + try { + await t.held; + assert.deepEqual(t.routed.map((r) => r.text), ['first'], 'nothing goes out while the first post is held'); + t.release(); + await t.run; + assert.equal(t.h.membrane.requests.length, 2); + assert.ok(t.restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(t.routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'second', channelId: 'chan-cafe' }, + { text: 'continued', channelId: 'chan-cafe' }, + ], 'the turn delivers in the order it wrote'); + assert.deepEqual(t.streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']); + } finally { + t.release(); + await t.h.framework.stop(); + } +}); + +test("locus mode: a resumed stream's deliveries queue behind the turn's earlier ones", { timeout: 15000 }, async () => { + const t = await heldRestart('locus', { first: 'first', second: 'second', continued: 'continued' }); + try { + await t.held; + assert.deepEqual(t.routed.map((r) => r.text), ['first'], 'nothing goes out while the first post is held'); + t.release(); + await t.run; + assert.ok(t.restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(t.routed.map((r) => r.text), ['first', 'second', 'continued'], 'the turn delivers in the order it wrote'); + } finally { + t.release(); + await t.h.framework.stop(); + } +}); + +/** + * The registry stub for one-stream explicit turns: #alpha and #cafe resolve, + * deliveries and stream chunks are recorded, and a `think` tool is offered. + */ +function explicitRegistry(h: Awaited>) { + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text: text.trim(), channelId }); + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + handleChannelToolCall: async () => ({ success: true, data: { noted: true } }), + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, + getChannelTools: () => [{ name: 'think', description: 'Reason privately', inputSchema: { type: 'object', properties: { content: { type: 'string' } } } }], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + return { routed, streamed }; +} + +test("explicit mode: within one stream, each round after a private round or >>skip_reply streams where delivery sends it", { timeout: 15000 }, async () => { + // The stream follows each prefix as it's written, a private round's too. + // At each round's end it returns to the turn's destination, which a private + // think's >>#cafe and a >>skip_reply envelope leave at #alpha. + const h = await harness([[ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([ + { type: 'text', text: '>>#cafe private plan' }, + { type: 'tool_use', id: 'two', name: 'think', input: { content: 'plan privately' } }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([ + { type: 'text', text: 'middle' }, + { type: 'tool_use', id: 'three', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([ + { type: 'text', text: '>>skip_reply aside' }, + { type: 'tool_use', id: 'four', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: 'later' }] as ContentBlock[]), + ]], { proseRouting: 'explicit', sameRoundThinkTextPolicy: 'private' }); + const { routed, streamed } = explicitRegistry(h); + try { + await h.run(); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'middle', channelId: 'chan-alpha' }, + { text: 'later', channelId: 'chan-alpha' }, + ]); + for (const text of ['middle', 'later']) { + assert.deepEqual(streamed.filter((d) => d.delta.includes(text)).map((d) => d.channelId), ['chan-alpha'], + `${text} streams to #alpha`); + } + } finally { + await h.framework.stop(); + } +}); + +test("explicit mode: a membrane retry within a stream starts again at the turn's destination", { timeout: 15000 }, async () => { + // Round one set #alpha. The membrane then refuses the next attempt and + // retries it; the retried attempt's unprefixed reply is delivered to + // #alpha, and streams there too. + const h = await harness([[ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: 'continued' }] as ContentBlock[]), + ]], { proseRouting: 'explicit' }); + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + for await (const event of events) { + if (event.type === 'tokens' && event.content === 'continued') { + yield { type: 'tokens', content: 'refused partial', meta: { type: 'text', visible: true, blockIndex: 0 } }; + yield { type: 'retrying', attempt: 1, maxAttempts: 3 }; + } + yield event; + } + }; + return stream; + }; + const { routed, streamed } = explicitRegistry(h); + try { + await h.run(); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'continued', channelId: 'chan-alpha' }, + ]); + assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-alpha']); + assert.equal(streamed.some((d) => d.delta.includes('refused partial')), true, + 'the refused attempt had streamed, as text streams before a refusal is known'); + } finally { + await h.framework.stop(); + } +}); + +test("explicit mode: a round's later text block starts a new line for the stream, as delivery joins blocks", { timeout: 15000 }, async () => { + // Two text blocks in one round, the second opening with a prefix, the first + // not ending with a newline. Delivery reads them joined by a newline. + const h = await harness([[ + createMockResponse([ + { type: 'text', text: '>>#alpha first part' }, + { type: 'text', text: '>>#cafe second' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: 'continued' }] as ContentBlock[]), + ]], { proseRouting: 'explicit' }); + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + let split = false; + for await (const event of events) { + if (!split && event.type === 'tokens' && typeof event.content === 'string' && event.content.includes('>>#cafe')) { + // The mock streams a round's text as one token: stream it as the + // two blocks the round holds. + split = true; + yield { type: 'tokens', content: '>>#alpha first part', meta: { type: 'text', visible: true, blockIndex: 0 } }; + yield { type: 'tokens', content: '>>#cafe second', meta: { type: 'text', visible: true, blockIndex: 1 } }; + continue; + } + yield event; + } + }; + return stream; + }; + const { routed, streamed } = explicitRegistry(h); + try { + await h.run(); + assert.deepEqual(routed.slice(0, 2), [ + { text: 'first part', channelId: 'chan-alpha' }, + { text: 'second', channelId: 'chan-cafe' }, + ]); + assert.deepEqual(streamed.filter((d) => d.delta.includes('second')).map((d) => d.channelId), ['chan-cafe']); + assert.equal(streamed.some((d) => d.delta.includes('>>')), false, 'no prefix is streamed as text'); + } finally { + await h.framework.stop(); + } +}); + +test("explicit mode: a round's targets are resolved once, when it ends, for its delivery and the resumed stream alike", { timeout: 15000 }, async () => { + // From a review's probe. Both rounds that name #cafe have ended, and their + // deliveries wait behind 'first', when the registry's #cafe starts naming + // another channel. Each goes to the channel #cafe named when its round + // ended, where the stream sent it: the queued '>>#cafe second', and the + // resumed '>>#cafe continued', whose text-only round ended before the wait. + const t = await heldRestart('explicit', { first: '>>#alpha first\n', second: '>>#cafe second', continued: '>>#cafe continued' }); + try { + await t.held; + t.directory.cafe = 'chan-rebound'; + t.release(); + await t.run; + assert.deepEqual(t.routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'second', channelId: 'chan-cafe' }, + { text: 'continued', channelId: 'chan-cafe' }, + ]); + assert.deepEqual(t.streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']); + } finally { + t.release(); + await t.h.framework.stop(); + } +}); + +test("a fresh turn that delivers nothing still replaces an earlier turn's speech chain for its restart", { timeout: 15000 }, async () => { + // An earlier turn's delivery that never settles: a stream resuming this + // turn must not queue behind it. + const quiet = createMockResponse([ + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'); + quiet.usage = { inputTokens: 100, outputTokens: 5 }; + // The reply names its destination, so nothing bounces into another turn. + const reply = createMockResponse([{ type: 'text', text: '>>#alpha continued' }] as ContentBlock[], 'end_turn'); + const h = await harness([[quiet], [reply]], { proseRouting: 'explicit', maxStreamTokens: 20 }); + (h.framework as unknown as { proseSpeechChains: Map> }) + .proseSpeechChains.set('assistant', new Promise(() => {})); + const routed: string[] = []; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push(text); + return { delivered: true, channelId }; + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + const run = h.run(); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<'timeout'>((resolve) => { timer = setTimeout(() => resolve('timeout'), 10000); }); + try { + assert.notEqual(await Promise.race([run.then(() => 'done' as const), timeout]), 'timeout', + 'the resumed turn finishes'); + } finally { + clearTimeout(timer); + } + assert.equal(h.membrane.requests.length, 2); + assert.deepEqual(routed.map((text) => text.trim()), ['continued']); + } finally { + await h.framework.stop(); + } +}); + +test("a fresh turn clears the sticky destination and the turn's destination in every mode", async () => { + for (const mode of ['explicit', 'hybrid', 'locus'] as const) { + const h = await harness([[answer()]], { proseRouting: mode }); + const internals = h.framework as unknown as { + proseTurnDestinations: Map; + proseTargetPins: Map; + }; + internals.proseTurnDestinations.set('assistant', 'chan-earlier'); + internals.proseTargetPins.set('assistant', 'chan-earlier'); + try { + await h.run(); + assert.equal(internals.proseTurnDestinations.has('assistant'), false, `${mode}: the turn's destination`); + assert.equal(internals.proseTargetPins.has('assistant'), false, `${mode}: the sticky destination`); + } finally { + await h.framework.stop(); + } + } +}); + +for (const shape of ['text-only', 'trailing'] as const) { + test(`explicit mode: a turn's ${shape} text is admitted before a module's speech callback runs`, { timeout: 15000 }, async () => { + // From a review's probe: a speech callback that rebinds #cafe can't move + // where the turn's final text goes, since that text was admitted first. + const scripts = shape === 'text-only' + ? [[createMockResponse([{ type: 'text', text: '>>#cafe final' }] as ContentBlock[])]] + : [[ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: '>>#cafe final' }] as ContentBlock[]), + ]]; + const h = await harness(scripts, { proseRouting: 'explicit' }); + let cafe = 'chan-cafe'; + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: cafe } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text: text.trim(), channelId }); + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + const modules = (h.framework as unknown as { moduleRegistry: { dispatchSpeech: (...args: unknown[]) => Promise } }).moduleRegistry; + const dispatch = modules.dispatchSpeech.bind(modules); + modules.dispatchSpeech = async (...args: unknown[]) => { + cafe = 'chan-rebound'; + await dispatch(...args); + }; + try { + await h.run(); + assert.deepEqual(routed.at(-1), { text: 'final', channelId: 'chan-cafe' }); + assert.ok(streamed.some((d) => d.channelId === 'chan-cafe' && d.delta.includes('final'))); + } finally { + await h.framework.stop(); + } + }); +} + test('backward-compatible direct Agent inference also guards tool results', async () => { const h = await harness([], { toolResultGuard: true }); const responses = [calls('one'), refused(), answer()];