App: a goal, a plan step and a /loop run without anyone there, so the agent's question is declined or skipped at once there and cannot stall them; normal chat still shows the question card (#4244)

This commit is contained in:
Niko1221 committed 2026-10-31 21:59:32 +01:00
1 parent a9a2939fe9
commit 2c2893081b
4 files changed
+25 -3

No files matched your search

+13
View File
@@ -634,6 +634,7 @@ cases.quote = async () => {
await page.locator('textarea').first().fill('SCRIPT:echo thanks for the quote');
await page.keyboard.press('Enter');
await page.locator('.sent-quote').first().waitFor({ timeout: 15000 });
await page.waitForTimeout(300);
const sent = await page.locator('.st-msg--user').last().innerText();
check(/Second point: nothing has to be uploaded/.test(sent) && /SCRIPT:echo thanks for the quote/.test(sent) && !/Quoting:/.test(sent) && !/「/.test(sent), `${tag}: the sent message shows the quote apart from the reply, without the markup`, sent.replace(/\n+/g, ' | ').slice(0, 160));
const got = worker.requests.at(-1)?.raw ?? '';
@@ -705,6 +706,18 @@ cases.ask = async () => {
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
// A /loop runs without anyone there: the question is skipped at once, no card, and the loop is not left waiting.
{
const before = (await asked()).length;
const page = await open('light', 1280, 860, [chat('s1', 'Loop', [])], { origin: t.origin });
await page.locator('textarea').first().fill('/loop 10m Build me a page stop after 1 answer');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => (await (await fetch('/__fixture/asked')).json()).length > 0, null, { timeout: 15000 }).catch(() => {});
const got = (await asked()).slice(before).at(-1);
check(got?.skip === true && got?.callId === 'call-ask-1', 'a loop: the question is skipped at once', JSON.stringify(got));
check(await page.locator('.askq').count() === 0, 'a loop: no question card is shown');
await page.context().close();
}
await fetch(`${t.origin}/__fixture/ask`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ on: false }) });
};
+3 -2
View File
@@ -278,14 +278,15 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
if (!r.callId) return;
pending.set(`${value.agentId}:${r.callId}`, { eventId: value.eventId, sessionId: value.agentId, callId: r.callId, tool: r.toolName, reason: r.reason ?? null, at: Date.now(), clientId: runtime.clientId });
} else if (value.type === 'waterfall' && value.event === 'user-questions/request') {
// The agent of a chat that is running asks the person: the question goes to the page (the question card above the message box) and the
// The agent of a chat that is running in front of the person asks the person: the question goes to the page (the question card above the message box) and the
// Harness waits for the answer. Anything else (a helper's question, a chat that is stopping, a request with nothing to show) is declared
// unanswerable, which makes the tool return an error the model reads and carries on from, instead of a turn that waits forever.
const r = value.request ?? {};
const chatId = chatOfSession(value.agentId);
const turn = chatId ? chats.get(chatId)?.turn : null;
const items = parseQuestions(r.questions);
if (!turn || turn.finished || turn.stopping || !turn.announceQuestion || !items.length) {
// A goal or a plan step runs without anyone there: a question would stall it, so it is declined at once (the model reads "could not be answered" and goes on).
if (!turn || turn.finished || turn.stopping || turn.goalRun || turn.plan || !turn.announceQuestion || !items.length) {
void runtime.gw.answerEvent({ clientId: runtime.clientId, eventId: value.eventId, outcome: 'next' }).catch(() => {});
} else {
const callId = String(r.wait?.callId ?? value.eventId);
+2
View File
@@ -639,6 +639,8 @@ function setAsks(sessionId: string, list: AgentAsk[]) {
/** The agent asked the person something (a frame of the host), or the question ended without an answer. */
export function addAgentAsk(sessionId: string, frame: { callId?: unknown; questions?: unknown }) {
const ask = readAsk(frame);
// A /loop runs without anyone there: the question is skipped at once, so the loop never waits for an answer.
if (ask && loopScheduler.isRunning(sessionId)) { void answerAgentAsk(sessionId, ask.callId, null).catch(() => {}); return; }
if (ask) setAsks(sessionId, [...(state.agentAsks[sessionId] ?? []).filter(a => a.callId !== ask.callId), ask]);
}
export function removeAgentAsk(sessionId: string, callId?: string) {
+7 -1
View File
@@ -443,7 +443,7 @@ test('#4244 the tool is offered again, as the group "Questions for you", and the
assert.match(catalog, /id: 'questions'[^\n]*plugins: \['tool-ask-user'\]/);
const bridge = readFileSync(new URL('../server/agent/agent-bridge.mjs', import.meta.url), 'utf8');
assert.match(bridge, /await rt\.gw\.answerEvent\(\{ clientId: entry\.clientId \?\? rt\.clientId, eventId: entry\.eventId, outcome \}\);/);
assert.match(bridge, /if \(!turn \|\| turn\.finished \|\| turn\.stopping \|\| !turn\.announceQuestion \|\| !items\.length\) \{\s*void runtime\.gw\.answerEvent\(\{ clientId: runtime\.clientId, eventId: value\.eventId, outcome: 'next' \}\)/, 'a helper question, a stopping chat or an empty request is still declared unanswerable');
assert.match(bridge, /if \(!turn \|\| turn\.finished \|\| turn\.stopping \|\| turn\.goalRun \|\| turn\.plan \|\| !turn\.announceQuestion \|\| !items\.length\) \{\s*void runtime\.gw\.answerEvent\(\{ clientId: runtime\.clientId, eventId: value\.eventId, outcome: 'next' \}\)/, 'a helper question, a stopping chat or an empty request is still declared unanswerable');
const gateway = readFileSync(new URL('../server/agent/gateway-client.mjs', import.meta.url), 'utf8');
assert.match(gateway, /const wire = outcome === 'next' \? \{ kind: 'next' \} : \{ kind: 'result', value: outcome \};/, 'a structured answer travels as the result value');
assert.match(src('components/ApprovalCard.tsx'), /asks\.map\(a => <QuestionCard key=\{a\.callId\} sessionId=\{sessionId!\} ask=\{a\}\/>\)/);
@@ -463,3 +463,9 @@ test('#3818 the transcript lets go of the newest token while the reader has word
assert.match(chat, /if \(pinned\.current && readerHasSelection\(el\)\) \{\s*\/\/[^\n]*\n\s*pinned\.current = false;\s*setAwayFromBottom\(distanceFromBottom\(el\) > PILL_DISTANCE\);\s*\}\s*if \(pinned\.current\)\s*el\.scrollTop = el\.scrollHeight;/, 'the observer that follows a growing reply');
assert.match(chat, /if \(pinned\.current && !sent && readerHasSelection\(el\)\) \{\s*pinned\.current = false;/, 'and the effect that follows each token; sending a message still brings the reader to it');
});
test('#4244 a goal, a plan step and a loop run without anyone there, so a question is declined at once instead of waiting', () => {
const bridge = readFileSync(new URL('../server/agent/agent-bridge.mjs', import.meta.url), 'utf8');
assert.match(bridge, /if \(!turn \|\| turn\.finished \|\| turn\.stopping \|\| turn\.goalRun \|\| turn\.plan \|\| !turn\.announceQuestion \|\| !items\.length\) \{\s*void runtime\.gw\.answerEvent\(\{ clientId: runtime\.clientId, eventId: value\.eventId, outcome: 'next' \}\)/);
assert.match(src('lib/store.ts'), /if \(ask && loopScheduler\.isRunning\(sessionId\)\) \{ void answerAgentAsk\(sessionId, ask\.callId, null\)\.catch\(\(\) => \{\}\); return; \}/);
});