question dismissal, A2: the silent write + the dismissal pause (owner design 2026-10-03)
- {type:"dismiss"} input event: parse + pure appends with the answer-event
orphan discipline (dangling-call 400, kind match, answer must be dismissed)
- POST /api/chats/[id]/dismiss: the no-turn composition — write-ahead append,
409 while a turn runs, emits record-changed (new bus word; idle views sync)
- needsContinuation: an all-dismissed trailing batch demands nothing;
endsInDismissal = the derived pause predicate (tail-based, no latch)
- wake pipeline: while the record ends in a dismissal the pump holds every
delivery in the steer queue — the user's next send is the wake (until the
text-turn steer claim lands, settlement re-pump delivers right after it)
- session-core.dismissQuestion: optimistic skipped card + silent POST;
chat.tsx confirmAbort question-mode swaps submitAnswer for it; ESC-abort
popup everywhere (AbortTurnDialog) with popup-Esc claimed via state gate
- parallel question calls dismiss call-by-call (results bind by id)
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* THE SILENT DISMISSAL ROUTE (owner design 2026-10-03, "A2"): ESC-abort
|
||||||
|
* from a pending question writes the dismissed toolResult to the record
|
||||||
|
* and wakes NOTHING — no beginTurn, no agent, no tokens. The session
|
||||||
|
* enters the derived dismissal pause (endsInDismissal): job deliveries
|
||||||
|
* hold in the wake pipeline's steer queue, the continuation fold demands
|
||||||
|
* nothing, and the user's next plain message is the wake that carries
|
||||||
|
* the dismissal (plus any held deliveries) into the agent's context.
|
||||||
|
*
|
||||||
|
* Parallel question calls: each dismissal completes exactly its own call
|
||||||
|
* (results bind by toolCallId, not position), so the dock advances
|
||||||
|
* call-by-call and the composer returns only when nothing dangles.
|
||||||
|
*
|
||||||
|
* Guards mirror the turn route's honesty: 404 unknown chat, 400 for an
|
||||||
|
* input that targets no dangling question call (or isn't a real
|
||||||
|
* dismissal), 409 when a turn is somehow running — the dismissing client
|
||||||
|
* heals via the record-changed sync, same as any raced settle.
|
||||||
|
*/
|
||||||
|
import { ChatInputError, dismissAppends, emit, parseDismissInput } from "@/src/core";
|
||||||
|
import { getChat, isTurnRunning, stores } from "@/src/server";
|
||||||
|
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const chat = getChat(id);
|
||||||
|
if (!chat) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
|
|
||||||
|
let input;
|
||||||
|
try {
|
||||||
|
// The POST-family convention: the body is { input } (chatId lives in
|
||||||
|
// the URL here).
|
||||||
|
const body = (await req.json().catch(() => null)) as { input?: unknown } | null;
|
||||||
|
input = parseDismissInput(body?.input ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dismissal can only target a settled question (question tools end
|
||||||
|
// their turn) — a running turn means something raced; refuse, never
|
||||||
|
// append around a live turn's own persist.
|
||||||
|
if (isTurnRunning(id)) {
|
||||||
|
return Response.json({ error: "A turn is running for this session" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let appends;
|
||||||
|
try {
|
||||||
|
appends = dismissAppends(chat, input);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write-ahead, turn-less: the record is the only state that moves.
|
||||||
|
const merged = [...chat.messages, ...appends];
|
||||||
|
stores.chatStore.saveMessages(id, merged);
|
||||||
|
emit("record-changed", { chatId: id });
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -17,8 +17,10 @@ import { createPortal } from "react-dom";
|
|||||||
*
|
*
|
||||||
* What "abort" means is mode-dependent: from a running turn it kills
|
* What "abort" means is mode-dependent: from a running turn it kills
|
||||||
* the turn (partial stays); from a pending question there is no turn to
|
* the turn (partial stays); from a pending question there is no turn to
|
||||||
* kill — abort IS dismissal (QuestionAnswerData.dismissed), and the
|
* kill — abort IS dismissal (A2, 2026-10-03): the silent route writes
|
||||||
* teacher acknowledges the skip in its next turn like any answer.
|
* the skipped toolResult and wakes NOTHING; the session pauses until
|
||||||
|
* the user's next plain message, which carries the dismissal to the
|
||||||
|
* model in its context.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** What Esc is aborting — drives copy and the confirm action. */
|
/** What Esc is aborting — drives copy and the confirm action. */
|
||||||
|
|||||||
+17
-5
@@ -187,7 +187,7 @@ export function Chat({
|
|||||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||||
const { messages, nativeMessages, send, submitAnswer, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
const { messages, nativeMessages, send, submitAnswer, dismissQuestion, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||||
|
|
||||||
// The reattach settled terminal → the sync-and-continue seam (below).
|
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||||
@@ -323,14 +323,15 @@ export function Chat({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// From a pending question there is no turn to kill — abort IS
|
// From a pending question there is no turn to kill — abort IS
|
||||||
// dismissal, riding the same answer path as any answer (the
|
// dismissal (A2, 2026-10-03): the skipped toolResult lands via the
|
||||||
// teacher acknowledges the skip in its next turn).
|
// SILENT route — no turn, no wake, nothing generates. The session
|
||||||
|
// pauses at the user's choice; the next plain send is the wake.
|
||||||
const p = pendingRef.current;
|
const p = pendingRef.current;
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
const question = (p.input as { question?: string } | null)?.question ?? "";
|
const question = (p.input as { question?: string } | null)?.question ?? "";
|
||||||
void submitAnswer(p.tool, p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question));
|
void dismissQuestion(p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question));
|
||||||
},
|
},
|
||||||
[abortInFlightTurn, submitAnswer],
|
[abortInFlightTurn, dismissQuestion],
|
||||||
);
|
);
|
||||||
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
||||||
// (a job result landed while no client was open), resume the lesson — the
|
// (a job result landed while no client was open), resume the lesson — the
|
||||||
@@ -879,6 +880,17 @@ export function Chat({
|
|||||||
// Fully idle — a turn we never saw (every window missed): adopt now.
|
// Fully idle — a turn we never saw (every window missed): adopt now.
|
||||||
void syncFromServer();
|
void syncFromServer();
|
||||||
});
|
});
|
||||||
|
useServerEvent("record-changed", (p) => {
|
||||||
|
// The silent dismissal write (A2, 2026-10-03): no turn ever ran, so
|
||||||
|
// only the record moved. Other tabs and post-error windows adopt;
|
||||||
|
// our OWN dismissal already rendered its optimistic append — syncing
|
||||||
|
// here too is harmless (the adoption law swaps local ids for the
|
||||||
|
// server's). Busy states ignore it: a running turn owns the view,
|
||||||
|
// and the write is 409-guarded against exactly that anyway.
|
||||||
|
if (p.chatId !== chatId) return;
|
||||||
|
if (localStreamRef.current || turnInFlightRef.current) return;
|
||||||
|
void syncFromServer();
|
||||||
|
});
|
||||||
|
|
||||||
const onSend = () => {
|
const onSend = () => {
|
||||||
if (busy || pending) return;
|
if (busy || pending) return;
|
||||||
|
|||||||
@@ -513,6 +513,30 @@ export class SessionModel {
|
|||||||
return this.send({ type: "answer", toolCallId, answer: output });
|
return this.send({ type: "answer", toolCallId, answer: output });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The dismissal path (owner design 2026-10-03, "A2"): the skipped
|
||||||
|
* toolResult lands optimistically, then the SILENT route persists it
|
||||||
|
* — no turn, no stream, nothing wakes. The session pauses at the
|
||||||
|
* user's choice; the next plain send is the wake. On failure the
|
||||||
|
* durable record wins: the error surfaces, the record-changed sync
|
||||||
|
* (or any later refetch) replaces the optimistic append with truth. */
|
||||||
|
async dismissQuestion(toolCallId: string, answer: QuestionAnswerData): Promise<void> {
|
||||||
|
if (!this.readyRef) return;
|
||||||
|
const tool = pendingCallTool([...this.base, ...this.tail], toolCallId) ?? "quiz";
|
||||||
|
this.mutate(() => {
|
||||||
|
this.tail = [...this.tail, { ...newAnswerResult(toolCallId, tool, answer), id: localId() }];
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await this.fetch(`/api/chats/${this.chatId}/dismiss`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ input: { type: "dismiss", toolCallId, answer } }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`The dismissal failed (${res.status}).`);
|
||||||
|
} catch (err) {
|
||||||
|
this.setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Local detach: cancel the consume, keep the partial tail. The page's
|
/** Local detach: cancel the consume, keep the partial tail. The page's
|
||||||
* settle watch adopts the record when the server turn finishes. */
|
* settle watch adopts the record when the server turn finishes. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
void model.submitAnswer(tool, toolCallId, output),
|
void model.submitAnswer(tool, toolCallId, output),
|
||||||
[model],
|
[model],
|
||||||
);
|
);
|
||||||
|
const dismissQuestion = useCallback(
|
||||||
|
(toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer),
|
||||||
|
[model],
|
||||||
|
);
|
||||||
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
||||||
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
||||||
const stop = useCallback(() => model.stop(), [model]);
|
const stop = useCallback(() => model.stop(), [model]);
|
||||||
@@ -106,6 +110,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
error: state.error,
|
error: state.error,
|
||||||
send,
|
send,
|
||||||
submitAnswer,
|
submitAnswer,
|
||||||
|
dismissQuestion,
|
||||||
setMessages,
|
setMessages,
|
||||||
adoptRecord,
|
adoptRecord,
|
||||||
stop,
|
stop,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export interface ServerEvents {
|
|||||||
"activity-changed": Record<string, never>;
|
"activity-changed": Record<string, never>;
|
||||||
/** The session list changed (create, delete, title). */
|
/** The session list changed (create, delete, title). */
|
||||||
"chats-changed": Record<string, never>;
|
"chats-changed": Record<string, never>;
|
||||||
|
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
|
||||||
|
* Same notify-then-fetch contract as the turn events: open views of
|
||||||
|
* the chat refetch and adopt the durable record. */
|
||||||
|
"record-changed": { chatId: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerEventType = keyof ServerEvents;
|
export type ServerEventType = keyof ServerEvents;
|
||||||
@@ -51,6 +55,7 @@ export const SERVER_EVENT_TYPES = [
|
|||||||
"jobs-changed",
|
"jobs-changed",
|
||||||
"activity-changed",
|
"activity-changed",
|
||||||
"chats-changed",
|
"chats-changed",
|
||||||
|
"record-changed",
|
||||||
] as const satisfies readonly ServerEventType[];
|
] as const satisfies readonly ServerEventType[];
|
||||||
|
|
||||||
/** One notification: the type discriminates the payload. */
|
/** One notification: the type discriminates the payload. */
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
newUserMessage,
|
newUserMessage,
|
||||||
} from "./native-messages.ts";
|
} from "./native-messages.ts";
|
||||||
import { QUESTION_TOOLS } from "./pending.ts";
|
import { QUESTION_TOOLS, toolQuestionKind } from "./pending.ts";
|
||||||
|
|
||||||
/** One client-originated input event (the POST body's `input`). */
|
/** One client-originated input event (the POST body's `input`). */
|
||||||
export type ChatInput =
|
export type ChatInput =
|
||||||
@@ -50,6 +50,45 @@ export type SteerInput = { type: "steers"; steers: LhMessage[] };
|
|||||||
/** Every input the turn composition accepts. */
|
/** Every input the turn composition accepts. */
|
||||||
export type TurnInput = ChatInput | SteerInput;
|
export type TurnInput = ChatInput | SteerInput;
|
||||||
|
|
||||||
|
/** The dismissal event (owner design 2026-10-03, "A2"): NEVER composes a
|
||||||
|
* turn — the silent route appends + persists and wakes nothing. The
|
||||||
|
* answer rides the event (mirroring the answer event) but must actually
|
||||||
|
* BE a dismissal: the silent path must never inject a live answer
|
||||||
|
* wake-free. Parallel question calls each get their own dismiss event;
|
||||||
|
* results reference their calls by id, so the appends stay independent. */
|
||||||
|
export type DismissInput = { type: "dismiss"; toolCallId: string; answer: QuestionAnswerData };
|
||||||
|
|
||||||
|
/** Parse and validate a dismissal event off a request body. */
|
||||||
|
export function parseDismissInput(input: unknown): DismissInput {
|
||||||
|
if (typeof input !== "object" || input === null) throw new ChatInputError("input must be an object");
|
||||||
|
const i = input as Record<string, unknown>;
|
||||||
|
if (i.type !== "dismiss") throw new ChatInputError(`unknown input type: ${String(i.type)}`);
|
||||||
|
if (typeof i.toolCallId !== "string" || i.toolCallId.length === 0) {
|
||||||
|
throw new ChatInputError("dismiss input requires toolCallId");
|
||||||
|
}
|
||||||
|
const answer = i.answer as QuestionAnswerData | undefined;
|
||||||
|
if (typeof answer !== "object" || answer === null) {
|
||||||
|
throw new ChatInputError("dismiss input requires the dismissal answer object");
|
||||||
|
}
|
||||||
|
if (answer.dismissed !== true) {
|
||||||
|
throw new ChatInputError("dismiss input's answer must be a dismissal (dismissed: true)");
|
||||||
|
}
|
||||||
|
return { type: "dismiss", toolCallId: i.toolCallId, answer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native appends a dismissal makes (pure — same orphan discipline
|
||||||
|
* as answers: the call must be a DANGLING question call). */
|
||||||
|
export function dismissAppends(record: { messages: LhMessage[] }, input: DismissInput): LhMessage[] {
|
||||||
|
const pending = danglingQuestionOf(record.messages);
|
||||||
|
if (!pending || pending.toolCallId !== input.toolCallId) {
|
||||||
|
throw new ChatInputError("dismiss input targets no pending question call");
|
||||||
|
}
|
||||||
|
if (input.answer.kind !== toolQuestionKind(pending.tool)) {
|
||||||
|
throw new ChatInputError("dismiss input's answer kind does not match the question");
|
||||||
|
}
|
||||||
|
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
|
||||||
|
}
|
||||||
|
|
||||||
/** A malformed input event (the honest 400 — the orphan-trap discipline). */
|
/** A malformed input event (the honest 400 — the orphan-trap discipline). */
|
||||||
export class ChatInputError extends Error {}
|
export class ChatInputError extends Error {}
|
||||||
|
|
||||||
|
|||||||
@@ -292,6 +292,26 @@ export function answeredCallIds(messages: readonly LhMessage[]): Set<string> {
|
|||||||
* dock's domain — fails the all-answered rule and never fires a turn,
|
* dock's domain — fails the all-answered rule and never fires a turn,
|
||||||
* exactly as the SDK's every(output-available) did).
|
* exactly as the SDK's every(output-available) did).
|
||||||
*/
|
*/
|
||||||
|
/** Is this record message a user-dismissed question result? The full
|
||||||
|
* answer rides the toolResult's `details` (newAnswerResult) — the
|
||||||
|
* dismissal pause and the continuation fold both read it. */
|
||||||
|
function isDismissedResult(m: LhMessage): boolean {
|
||||||
|
if ((m as { role?: string }).role !== "toolResult") return false;
|
||||||
|
const details = (m as { details?: unknown }).details as { dismissed?: unknown } | undefined;
|
||||||
|
return details?.dismissed === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dismissal pause (owner design 2026-10-03, "A2"): the record ends
|
||||||
|
* in user dismissal — the session waits for the USER. While paused,
|
||||||
|
* nothing auto-wakes: the continuation fold demands nothing (below) and
|
||||||
|
* the wake pipeline holds job deliveries in the steer queue until the
|
||||||
|
* next send. Derived from the tail — no latch to persist or clear; any
|
||||||
|
* user message flips it off by changing the record. */
|
||||||
|
export function endsInDismissal(messages: readonly LhMessage[]): boolean {
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
return last !== undefined && isDismissedResult(last);
|
||||||
|
}
|
||||||
|
|
||||||
export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
||||||
const last = messages[messages.length - 1];
|
const last = messages[messages.length - 1];
|
||||||
if (!last || (last as { role?: string }).role !== "toolResult") return false;
|
if (!last || (last as { role?: string }).role !== "toolResult") return false;
|
||||||
@@ -305,6 +325,11 @@ export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
|||||||
) {
|
) {
|
||||||
start--;
|
start--;
|
||||||
}
|
}
|
||||||
|
// The dismissal rule (A2): a trailing batch that is ALL user dismissals
|
||||||
|
// owes the agent nothing — the session sits paused at the user's
|
||||||
|
// choice. Mixed batches (impossible while paused — held deliveries
|
||||||
|
// never append — but honest anyway) fall through to the normal rule.
|
||||||
|
if (messages.slice(start).every(isDismissedResult)) return false;
|
||||||
const owner = messages[start - 1];
|
const owner = messages[start - 1];
|
||||||
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
|
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
|
||||||
const content = (owner as { content?: unknown }).content;
|
const content = (owner as { content?: unknown }).content;
|
||||||
|
|||||||
+12
-1
@@ -64,7 +64,7 @@ import {
|
|||||||
type SteerMessage,
|
type SteerMessage,
|
||||||
} from "../core/contracts.ts";
|
} from "../core/contracts.ts";
|
||||||
import type { TurnInput } from "../core/input-events.ts";
|
import type { TurnInput } from "../core/input-events.ts";
|
||||||
import { danglingQuestionOf } from "../core/native-messages.ts";
|
import { danglingQuestionOf, endsInDismissal } from "../core/native-messages.ts";
|
||||||
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
import { isTurnRunning } from "./turn-liveness.ts";
|
import { isTurnRunning } from "./turn-liveness.ts";
|
||||||
@@ -457,6 +457,17 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record
|
||||||
|
// ends in a user dismissal — the session waits for the USER. Hold
|
||||||
|
// every marker in the queue (no wake fires) and return; the user's
|
||||||
|
// next send is the wake that carries them (the in-run vehicle below
|
||||||
|
// hands them to that turn once the text-claim lands — until then,
|
||||||
|
// settlement re-pumps and lands them right after it). The pause is
|
||||||
|
// derived from the tail, so any user message un-holds by construction.
|
||||||
|
if (endsInDismissal(fresh.messages)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression):
|
// DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression):
|
||||||
// the carried markers leave the registry BEFORE the turn runs —
|
// the carried markers leave the registry BEFORE the turn runs —
|
||||||
// the wake run's OWN follow-up poll must never see its own cargo,
|
// the wake run's OWN follow-up poll must never see its own cargo,
|
||||||
|
|||||||
+97
-2
@@ -35,11 +35,16 @@ import { buildSubagentSteer } from "../src/core/contracts.ts";
|
|||||||
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
import { buildAnswer, coerceCorrectAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||||
import { danglingQuestionOf, newAnswerResult } from "../src/core/native-messages.ts";
|
import { danglingQuestionOf, endsInDismissal, newAnswerResult } from "../src/core/native-messages.ts";
|
||||||
import { projectView } from "../src/core/view.ts";
|
import { projectView } from "../src/core/view.ts";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
|
||||||
|
// Route-level pin (the chat-birth-stamp pattern): the dismiss route's
|
||||||
|
// handler, driven directly. Env is the runner's (the same the static
|
||||||
|
// store imports already resolved under).
|
||||||
|
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
||||||
@@ -1155,3 +1160,93 @@ it("explain tool call persists as a pending part; the free-text answer reveals t
|
|||||||
assert.ok(!/"explanation"/.test(seen), "the answer record carries no explanation field");
|
assert.ok(!/"explanation"/.test(seen), "the answer record carries no explanation field");
|
||||||
assert.equal(danglingQuestionOf(saved2.messages), null, "no pending question remains");
|
assert.equal(danglingQuestionOf(saved2.messages), null, "no pending question remains");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the silent dismissal (owner design 2026-10-03, "A2") ──────────────────
|
||||||
|
|
||||||
|
describe("the silent dismissal (A2)", () => {
|
||||||
|
function dismissPost(chatId: string, toolCallId: string): Promise<Response> {
|
||||||
|
return dismissRoute.POST(
|
||||||
|
new Request(`http://test/api/chats/${chatId}/dismiss`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: { type: "dismiss", toolCallId, answer: dismissedAnswer("quiz", QUIZ_INPUT.question) },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ id: chatId }) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("writes the toolResult with NO wake — the next plain message carries it to the model", async () => {
|
||||||
|
const { engine, calls } = scriptedTeacher(defaultLesson);
|
||||||
|
const chat = createConfiguredChat("Test: silent dismissal");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
// Turn 1: the quiz dangles.
|
||||||
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine)));
|
||||||
|
assert.ok(danglingQuestionOf(chatStore.getChat(chat.id)!.messages), "the quiz is pending");
|
||||||
|
|
||||||
|
// The dismissal: SILENT — record completes the call, nothing wakes.
|
||||||
|
const res = await dismissPost(chat.id, "call-1");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
|
assert.equal(danglingQuestionOf(saved.messages), null, "the call is answered by the dismissal");
|
||||||
|
const tail = saved.messages[saved.messages.length - 1]! as { role?: string; details?: { dismissed?: boolean } };
|
||||||
|
assert.equal(tail.role, "toolResult");
|
||||||
|
assert.equal(tail.details?.dismissed, true, "the record's dismissal is the details payload");
|
||||||
|
assert.equal(calls.length, 1, "NO second engine call — the dismissal woke nothing");
|
||||||
|
assert.equal(isTurnRunning(chat.id), false);
|
||||||
|
assert.equal(endsInDismissal(saved.messages), true, "the session sits dismissal-paused");
|
||||||
|
|
||||||
|
// The next plain message is THE wake — the model sees the skipped
|
||||||
|
// question and the user's text in one context (the steer-batch
|
||||||
|
// outcome, delivered by record truth).
|
||||||
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "skip that — tell me about Mars" }), agentFactory(engine)));
|
||||||
|
assert.equal(calls.length, 2, "exactly one wake: the user's message");
|
||||||
|
assert.match(calls[1]!.join("\n"), /dismissed/, "the dismissed result rode the wake's context");
|
||||||
|
assert.equal(endsInDismissal(chatStore.getChat(chat.id)!.messages), false, "the pause lifted with the send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects honestly: unknown chat 404, no dangling question 400", async () => {
|
||||||
|
assert.equal((await dismissPost("chat_nonexistent", "call-x")).status, 404);
|
||||||
|
const chat = createConfiguredChat("Test: dismiss no question");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hi", timestamp: 1 } as never]);
|
||||||
|
assert.equal((await dismissPost(chat.id, "call-x")).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parallel quizzes dismiss call-by-call — the composer returns only when nothing dangles", async () => {
|
||||||
|
const chat = createConfiguredChat("Test: parallel dismiss");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
// Two quiz calls in one assistant message (the sequential-execution
|
||||||
|
// shape the tools opt into).
|
||||||
|
chatStore.saveMessages(chat.id, [
|
||||||
|
{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never,
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call-a", name: "quiz", arguments: { question: "A?" } as never },
|
||||||
|
{ type: "toolCall", id: "call-b", name: "quiz", arguments: { question: "B?" } as never },
|
||||||
|
],
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "toolUse",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First dismissal completes call-a; call-b still dangles.
|
||||||
|
assert.equal((await dismissPost(chat.id, "call-a")).status, 200);
|
||||||
|
const pending = danglingQuestionOf(chatStore.getChat(chat.id)!.messages);
|
||||||
|
assert.equal(pending?.toolCallId, "call-b", "the dock advances to the next question");
|
||||||
|
assert.equal(endsInDismissal(chatStore.getChat(chat.id)!.messages), true);
|
||||||
|
|
||||||
|
// Second dismissal: nothing dangles, still paused.
|
||||||
|
assert.equal((await dismissPost(chat.id, "call-b")).status, 200);
|
||||||
|
assert.equal(danglingQuestionOf(chatStore.getChat(chat.id)!.messages), null, "the composer may return");
|
||||||
|
assert.equal(endsInDismissal(chatStore.getChat(chat.id)!.messages), true, "paused until the user speaks");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -854,6 +854,61 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
|||||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the dismissal pause holds deliveries — no wake until the user's next turn settles (A2)", async () => {
|
||||||
|
const { chatId, jobId, steer } = setup();
|
||||||
|
let fired = 0;
|
||||||
|
setTurnRunner(async () => {
|
||||||
|
fired++;
|
||||||
|
return 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The record ends in a DISMISSAL (owner design 2026-10-03): the
|
||||||
|
// question was skipped via the silent route — no dangling call (the
|
||||||
|
// quiz gate would pass), so ONLY the dismissal pause holds the wake.
|
||||||
|
chatStore.saveMessages(chatId, [
|
||||||
|
{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never,
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q" } as never }],
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "toolUse",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "quiz-call",
|
||||||
|
toolName: "quiz",
|
||||||
|
content: [{ type: "text", text: "skipped" }],
|
||||||
|
details: { kind: "quiz", questionText: "q", dontKnow: false, answers: [], dismissed: true, answeredAt: "t" },
|
||||||
|
isError: false,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const delivery = deliverJobResult(steer);
|
||||||
|
|
||||||
|
await delay(1200);
|
||||||
|
assert.equal(fired, 0, "no wake while the session sits dismissal-paused");
|
||||||
|
assert.equal(jobStore.getJob(jobId)!.status, "running", "the delivery holds (queued, not lost)");
|
||||||
|
|
||||||
|
// The user's next message is the wake — its turn settles, and the
|
||||||
|
// settlement re-pump lands the held delivery right after it.
|
||||||
|
const record = chatStore.getChat(chatId)!;
|
||||||
|
const afterUser = [...record.messages, { id: "u2", role: "user", content: "moving on", timestamp: 2 } as never];
|
||||||
|
chatStore.saveMessages(chatId, afterUser);
|
||||||
|
settleSteers(chatId, afterUser);
|
||||||
|
|
||||||
|
await waitFor(() => fired > 0, 8000, "wake after the pause lifts");
|
||||||
|
await delivery;
|
||||||
|
await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "job flip after the held wake");
|
||||||
|
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||||
|
});
|
||||||
|
|
||||||
it("retries with backoff on a 409 (a client turn raced in) and delivers on the retry", async () => {
|
it("retries with backoff on a 409 (a client turn raced in) and delivers on the retry", async () => {
|
||||||
const { jobId, steer } = setup();
|
const { jobId, steer } = setup();
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import { afterEach, describe, it } from "node:test";
|
|||||||
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
|
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
|
||||||
import type { ChatInput } from "../src/core/input-events.ts";
|
import type { ChatInput } from "../src/core/input-events.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import { newUserMessage, newAnswerResult, needsContinuation } from "../src/core/native-messages.ts";
|
import { newUserMessage, newAnswerResult, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts";
|
||||||
|
import { dismissedAnswer } from "../src/core/question-core.ts";
|
||||||
import { buildAnswer, type QuestionData } from "../src/core/question-core.ts";
|
import { buildAnswer, type QuestionData } from "../src/core/question-core.ts";
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
@@ -817,6 +818,71 @@ describe("needsContinuation (the native lastAssistantMessageIsCompleteWithToolCa
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the dismissal pause (owner design 2026-10-03, "A2") ────────────────────
|
||||||
|
|
||||||
|
describe("the dismissal pause (needsContinuation + endsInDismissal)", () => {
|
||||||
|
/** The record's silent dismiss write: the toolResult the dismiss route
|
||||||
|
* appends (full answer in details — the folds read it there). */
|
||||||
|
function dismissedMsg(callId: string): LhMessage {
|
||||||
|
return {
|
||||||
|
...newAnswerResult(callId, "quiz", dismissedAnswer("quiz", "Pick a planet")),
|
||||||
|
id: `d_${callId}`,
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("an all-dismissed tail demands NO continuation — the session waits for the user", () => {
|
||||||
|
const messages = [userText("u1", "topic"), assistantWithCall("a1", "call-q1", "quiz"), dismissedMsg("call-q1")];
|
||||||
|
assert.equal(needsContinuation(messages), false);
|
||||||
|
assert.equal(endsInDismissal(messages), true, "the pause predicate reads the same tail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parallel dismissals: each silent write its own call — still paused", () => {
|
||||||
|
const assistant = {
|
||||||
|
...assistantWithCall("a1", "call-a"),
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call-a", name: "quiz", arguments: {} },
|
||||||
|
{ type: "toolCall", id: "call-b", name: "ask_user_question", arguments: {} },
|
||||||
|
],
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
const messages = [
|
||||||
|
userText("u1", "topic"),
|
||||||
|
assistant,
|
||||||
|
dismissedMsg("call-a"),
|
||||||
|
{ ...newAnswerResult("call-b", "ask_user_question", dismissedAnswer("ask", "Why?")), id: "d_call-b" } as unknown as LhMessage,
|
||||||
|
];
|
||||||
|
assert.equal(endsInDismissal(messages), true);
|
||||||
|
assert.equal(needsContinuation(messages), false, "dismissed by id, not by position — the batch rule holds");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixed tail (answered + dismissed) keeps the normal rule — the answer is owed its reaction", () => {
|
||||||
|
const assistant = {
|
||||||
|
...assistantWithCall("a1", "call-a"),
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call-a", name: "quiz", arguments: {} },
|
||||||
|
{ type: "toolCall", id: "call-b", name: "quiz", arguments: {} },
|
||||||
|
],
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
const messages = [userText("u1", "topic"), assistant, resultMsg("call-a", "quiz"), dismissedMsg("call-b")];
|
||||||
|
assert.equal(endsInDismissal(messages), true, "tail ends in the dismissal");
|
||||||
|
assert.equal(needsContinuation(messages), true, "a real answer in the batch still demands the reaction");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any user message un-pauses (the derived tail flips)", () => {
|
||||||
|
const messages = [
|
||||||
|
userText("u1", "topic"),
|
||||||
|
assistantWithCall("a1", "call-q1", "quiz"),
|
||||||
|
dismissedMsg("call-q1"),
|
||||||
|
userText("u2", "moving on"),
|
||||||
|
];
|
||||||
|
assert.equal(endsInDismissal(messages), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a normal answer is not a dismissal (the predicate stays honest)", () => {
|
||||||
|
const messages = [userText("u1", "topic"), assistantWithCall("a1", "call-q1", "quiz"), resultMsg("call-q1", "quiz")];
|
||||||
|
assert.equal(endsInDismissal(messages), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── the pending-call tool scan ─────────────────────────────────────────────
|
// ─── the pending-call tool scan ─────────────────────────────────────────────
|
||||||
|
|
||||||
describe("pendingCallTool", () => {
|
describe("pendingCallTool", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user