108 lines
6.2 KiB
TypeScript
108 lines
6.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { findPendingToolCall } from "../src/core/pending.ts";
|
|
import { danglingQuestionOf, pendingQuestionOf, newAnswerResult, newUserMessage, type LhMessage } from "../src/core/native-messages.ts";
|
|
import { inputAppends } from "../src/core/input-events.ts";
|
|
import { projectView } from "../src/core/view.ts";
|
|
|
|
function assistant(id: string, ...calls: string[]): LhMessage {
|
|
return {
|
|
id, role: "assistant", content: calls.map(id => ({ type: "toolCall", id, name: "quiz", arguments: { question: id } })),
|
|
stopReason: "toolUse", timestamp: 1, api: "openai-completions", provider: "fixture", model: "fixture",
|
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
};
|
|
}
|
|
|
|
function pending(messages: LhMessage[], view = projectView(messages)) {
|
|
return findPendingToolCall(view, pendingQuestionOf(messages));
|
|
}
|
|
|
|
test("a later assistant question supersedes an unanswered question in the same projected group", () => {
|
|
const messages = [assistant("old", "old-question"), assistant("new", "new-question")];
|
|
assert.equal(projectView(messages).length, 1, "the view merges both assistant boundaries");
|
|
const question = pending(messages)!;
|
|
assert.equal(question.toolCallId, "new-question");
|
|
assert.doesNotThrow(() => inputAppends({ messages }, { type: "answer", toolCallId: question.toolCallId, answer: { kind: "quiz", answers: [], dontKnow: true } as never }));
|
|
});
|
|
|
|
test("the newest assistant's parallel questions retain call order and never revive older questions", () => {
|
|
const messages = [assistant("old", "old-question"), assistant("new", "first", "second")];
|
|
assert.equal(pending(messages)?.toolCallId, "first");
|
|
messages.push({ ...newAnswerResult("first", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "answer-first" });
|
|
assert.equal(pending(messages)?.toolCallId, "second");
|
|
messages.push({ ...newAnswerResult("second", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "answer-second" });
|
|
assert.equal(pending(messages), null);
|
|
});
|
|
|
|
test("the authoritative question still waits for projected input validation", () => {
|
|
const messages = [assistant("old", "old-question"), assistant("new", "new-question")];
|
|
const view = projectView(messages);
|
|
for (const message of view) for (const part of message.parts) {
|
|
if (part.type === "tool-quiz" && part.toolCallId === "new-question") part.state = "input-streaming";
|
|
}
|
|
assert.equal(pending(messages, view), null);
|
|
});
|
|
|
|
// Synthetic compatibility cases, not claims about the affected user's history.
|
|
test("reused ids select the newest native question's input, not an older available part", () => {
|
|
const old = assistant("old", "call_0");
|
|
const newer = assistant("new", "call_0");
|
|
if (newer.role === "assistant") newer.content.unshift({ type: "text", text: "new teaching" });
|
|
if (newer.role === "assistant" && newer.content[1]?.type === "toolCall") newer.content[1].arguments = { question: "NEW" };
|
|
const messages = [old, newer];
|
|
const question = pending(messages)!;
|
|
assert.equal(question.messageId, "new");
|
|
assert.equal(question.contentIndex, 1);
|
|
assert.deepEqual(question.input, { question: "NEW" });
|
|
assert.notEqual(question.key, pending([old])!.key);
|
|
assert.deepEqual(danglingQuestionOf(messages), { toolCallId: "call_0", tool: "quiz", input: { question: "NEW" } }, "server authority/shape unchanged");
|
|
const answered = inputAppends({ messages }, { type: "answer", toolCallId: question.toolCallId, answer: { kind: "quiz", answers: [] } as never });
|
|
const parts = projectView([...messages, ...answered]).flatMap(m => m.parts).filter(p => p.type === "tool-quiz");
|
|
assert.equal(parts[0]!.state, "input-available", "old transcript card is untouched");
|
|
assert.equal(parts[1]!.state, "output-available", "result still pairs newest-first");
|
|
assert.equal(pending([...messages, ...answered]), null);
|
|
});
|
|
|
|
for (const state of ["input-streaming", "output-error", "output-available"]) {
|
|
test(`an older available duplicate never bypasses the authoritative part's ${state} state`, () => {
|
|
const messages = [assistant("old", "call_0"), assistant("new", "call_0")];
|
|
const view = projectView(messages);
|
|
const ownPart = view.flatMap(m => m.parts).find(p => p.sourceMessageId === "new" && p.type === "tool-quiz")!;
|
|
ownPart.state = state;
|
|
assert.equal(pending(messages, view), null);
|
|
});
|
|
}
|
|
|
|
test("a missing authoritative part cannot fall back to another occurrence", () => {
|
|
const messages = [assistant("old", "call_0"), assistant("new", "call_0")];
|
|
assert.equal(pending(messages, projectView(messages.slice(0, 1))), null);
|
|
});
|
|
|
|
test("older questions never revive across display groups or after a text-only newest assistant", () => {
|
|
const messages = [assistant("old", "old-q"), newUserMessage("another turn"), assistant("new", "new-q")];
|
|
assert.equal(pending(messages)?.toolCallId, "new-q");
|
|
assert.equal(pending([...messages, newAnswerResult("new-q", "quiz", {} as never)]), null);
|
|
const textOnly = { ...assistant("text"), content: [{ type: "text", text: "done" }] } as LhMessage;
|
|
assert.equal(pending([...messages, textOnly]), null);
|
|
});
|
|
|
|
test("unique question keys survive live-to-persisted message stamps without resetting drafts", () => {
|
|
const live = assistant("c_live", "unique-q");
|
|
const persisted = { ...live, id: "persisted" };
|
|
assert.equal(pending([live])!.key, pending([persisted])!.key);
|
|
assert.notEqual(pending([live])!.messageId, pending([persisted])!.messageId);
|
|
});
|
|
|
|
test("native occurrence checks preserve every question tool and its own live validation gate", () => {
|
|
for (const tool of ["quiz", "probe", "ask_user_question", "explain", "recall"]) {
|
|
const message = assistant("live", "q");
|
|
if (message.role === "assistant" && message.content[0]?.type === "toolCall") message.content[0].name = tool;
|
|
const live = { executions: new Map(), inFlight: true, streamingIds: new Set(["live"]) };
|
|
assert.equal(pending([message], projectView([message], live)), null);
|
|
live.executions.set("q", { isError: false });
|
|
assert.equal(pending([message], projectView([message], live))?.tool, tool);
|
|
live.executions.set("q", { isError: true });
|
|
assert.equal(pending([message], projectView([message], live)), null);
|
|
}
|
|
});
|