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:
@@ -17,8 +17,10 @@ import { createPortal } from "react-dom";
|
||||
*
|
||||
* 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
|
||||
* kill — abort IS dismissal (QuestionAnswerData.dismissed), and the
|
||||
* teacher acknowledges the skip in its next turn like any answer.
|
||||
* kill — abort IS dismissal (A2, 2026-10-03): the silent route writes
|
||||
* 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. */
|
||||
|
||||
+17
-5
@@ -187,7 +187,7 @@ export function Chat({
|
||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||
// 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 });
|
||||
|
||||
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||
@@ -323,14 +323,15 @@ export function Chat({
|
||||
return;
|
||||
}
|
||||
// From a pending question there is no turn to kill — abort IS
|
||||
// dismissal, riding the same answer path as any answer (the
|
||||
// teacher acknowledges the skip in its next turn).
|
||||
// dismissal (A2, 2026-10-03): the skipped toolResult lands via the
|
||||
// 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;
|
||||
if (!p) return;
|
||||
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
|
||||
// (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.
|
||||
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 = () => {
|
||||
if (busy || pending) return;
|
||||
|
||||
@@ -513,6 +513,30 @@ export class SessionModel {
|
||||
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
|
||||
* settle watch adopts the record when the server turn finishes. */
|
||||
stop(): void {
|
||||
|
||||
@@ -93,6 +93,10 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
void model.submitAnswer(tool, toolCallId, output),
|
||||
[model],
|
||||
);
|
||||
const dismissQuestion = useCallback(
|
||||
(toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer),
|
||||
[model],
|
||||
);
|
||||
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
||||
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
||||
const stop = useCallback(() => model.stop(), [model]);
|
||||
@@ -106,6 +110,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
error: state.error,
|
||||
send,
|
||||
submitAnswer,
|
||||
dismissQuestion,
|
||||
setMessages,
|
||||
adoptRecord,
|
||||
stop,
|
||||
|
||||
Reference in New Issue
Block a user