diff --git a/changelog.d/207-task-input-waits.added.md b/changelog.d/207-task-input-waits.added.md new file mode 100644 index 00000000..aee38f8c --- /dev/null +++ b/changelog.d/207-task-input-waits.added.md @@ -0,0 +1 @@ +The task API can record a bounded clarification question and resume on its requester's matching answer. Pending send approvals remain separate, and `task-start` can no longer bypass a blocked input or approval wait. No question delivery or agent execution is triggered. ([#207](https://github.com/ThinkFlowLab/FlyBot/issues/207)) diff --git a/core/dev/smoke.mjs b/core/dev/smoke.mjs index 5fa04e6a..140165e9 100644 --- a/core/dev/smoke.mjs +++ b/core/dev/smoke.mjs @@ -358,6 +358,22 @@ await step('detached capabilities disappear from Chief', () => send(chief().id, return expect(report?.endsWith(seesNothing), `Chief reported ${report}`) }) +let inputTaskId +const inputWait = await step('task records an input wait without a model call', async () => { + const task = await api('task-create', { botId: chief().id, title: 'Input smoke', criteria: 'Record selected input' }) + inputTaskId = task.id + await api('task-accept', { id: task.id }) + await api('task-start', { id: task.id }) + return api('task-wait-input', { id: task.id, question: 'How many retry attempts?' }) +}, (value, calls) => expect(value.status === 'blocked' && value.waitingFor === 'input' && typeof value.inputRequest?.id === 'string', 'expected a current input question') + ?? expect(calls.length === 0, 'task bookkeeping must not call the model')) + +await step('matching task input resumes bookkeeping without a model call', () => api('task-answer-input', { + id: inputTaskId, questionId: inputWait?.inputRequest?.id, answer: 'Three attempts, checking cancellation each time.', +}), (value, calls) => expect(value.status === 'working' && value.waitingFor === undefined && value.inputRequest?.answeredAt !== undefined, 'expected answered input and working status') + ?? expect(calls.length === 0, 'answering task input must not call the model')) +if (inputTaskId) await api('task-cancel', { id: inputTaskId }) + if (outFile) writeFileSync(outFile, `${JSON.stringify(transcript, null, 2)}\n`) console.log(failures.length === 0 ? `\nsmoke: all ${transcript.length} steps passed` : `\nsmoke: ${failures.length} failed\n${failures.join('\n')}`) process.exit(failures.length === 0 ? 0 : 1) diff --git a/core/src/host/authz.js b/core/src/host/authz.js index ddb529c2..eef95f56 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -53,6 +53,7 @@ const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss // here keeps a future `task-*` from auto-allowing for any signed-in actor (#111). const TASK_ENDPOINTS = new Set([ 'tasks', 'task-create', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-cancel', + 'task-wait-input', 'task-answer-input', ]) const deny = (code, message) => ({ ok: false, code, message }) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index ae4c32b0..67f8001f 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -443,6 +443,8 @@ export function install(rt) { case 'task-return': case 'task-accept-result': case 'task-cancel': + case 'task-wait-input': + case 'task-answer-input': await rt.load() return ok(await rt.taskEndpoint(endpoint, body, actor)) case 'share-brief': diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index 7a7af572..e0c34b89 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -33,6 +33,13 @@ const KEPT = 200 const stale = message => Object.assign(new Error(message), { code: 'bot/stale' }) const forbidden = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) +const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) +const waitingForInput = task => task.status === 'blocked' && task.waitingFor === 'input' +const canLinkSend = task => task.status === 'working' || waitingForInput(task) +const selectedInput = (value, name) => { + if (typeof value !== 'string' || !value.trim() || value.length > 2000) throw invalid(`${name} must contain 1–2000 characters`) + return value +} export function install(rt) { const { state } = rt @@ -71,11 +78,19 @@ export function install(rt) { && live.some(p => p.kind === 'bot' && !p.remote && p.id === bot.id) } + function record(task, actor, extra) { + task.updatedAt = Date.now() + task.actors.push({ status: task.status, at: task.updatedAt, ...(actor?.memberId ? { memberId: actor.memberId } : {}), ...extra }) + } function transition(task, to, actor, extra) { if (!TRANSITIONS[task.status]?.includes(to)) throw stale(`a task that is ${task.status} cannot become ${to}`) + if (waitingForInput(task) && to !== 'blocked') { + delete task.waitingFor + delete task.inputApprovalIds + delete task.approvalId + } task.status = to - task.updatedAt = Date.now() - task.actors.push({ status: to, at: task.updatedAt, ...(actor?.memberId ? { memberId: actor.memberId } : {}), ...extra }) + record(task, actor, extra) } const view = task => ({ ...task, actors: [...task.actors ?? []] }) @@ -90,7 +105,8 @@ export function install(rt) { // Name the working task a send belongs to so a hold can block that one (#111). // Prefer an already-stamped `meta.taskId`; else the Bot's only working task; // else the one whose origin matches this send's session/room; else the most - // recently started. Clarification / workBotId still wait on #55. + // recently started. An explicit id also links a task waiting for input; do not + // infer that binding from an unrelated Bot conversation. workBotId waits on #55. rt.taskIdForSend = (msg) => { const from = msg?.from if (typeof from !== 'string') return undefined @@ -98,7 +114,7 @@ export function install(rt) { const given = typeof msg?.meta?.taskId === 'string' ? msg.meta.taskId : undefined if (given) { const named = store()[given] - if (named && named.status === 'working' && ofSender(named)) return given + if (named && canLinkSend(named) && ofSender(named)) return given } const working = Object.values(store()).filter(task => task.status === 'working' && ofSender(task)) if (working.length === 0) return undefined @@ -120,10 +136,16 @@ export function install(rt) { const named = namedId ? store()[namedId] : undefined const ofSender = task => task.ownerBotId === msg.from || task.workBotId === msg.from const working = Object.values(store()).filter(task => task.status === 'working' && ofSender(task)) - const targets = named && named.status === 'working' && ofSender(named) + const targets = named && canLinkSend(named) && ofSender(named) ? [named] : working.length === 1 ? working : [] for (const task of targets) { + if (waitingForInput(task)) { + task.inputApprovalIds = [...new Set([...(task.inputApprovalIds ?? []), approval.id])] + task.approvalId = task.inputApprovalIds[0] + record(task, null, { approvalId: approval.id, by: 'approval while awaiting input' }) + continue + } task.approvalId = approval.id transition(task, 'blocked', null, { approvalId: approval.id }) } @@ -132,6 +154,17 @@ export function install(rt) { // The owner's decision on that approval resumes the task — or, on reject, ends it. rt.taskDecided = (approval, decision) => { for (const task of Object.values(store())) { + if (waitingForInput(task) && task.inputApprovalIds?.includes(approval.id)) { + if (decision !== 'approve') { + transition(task, 'cancelled', { memberId: approval.ownerId }, { approvalId: approval.id, by: 'owner decision' }) + continue + } + task.inputApprovalIds = task.inputApprovalIds.filter(id => id !== approval.id) + if (task.inputApprovalIds.length) task.approvalId = task.inputApprovalIds[0] + else delete task.approvalId + record(task, { memberId: approval.ownerId }, { approvalId: approval.id, by: 'approved; input still required' }) + continue + } if (task.status !== 'blocked' || task.approvalId !== approval.id) continue delete task.approvalId transition(task, decision === 'approve' ? 'working' : 'cancelled', { memberId: approval.ownerId }, { by: 'owner decision' }) @@ -192,6 +225,7 @@ export function install(rt) { const task = find(body.id) if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) requireRequester(task, actor) + if (task.status !== 'claimed') throw stale('Only a claimed task can start; blocked tasks must clear their input or approval wait') transition(task, 'working', actor) await save() return view(task) @@ -205,6 +239,31 @@ export function install(rt) { await save() return view(task) } + case 'task-wait-input': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + if (task.status !== 'working' || task.approvalId) throw stale('Only a working task without an approval wait may request input') + const question = selectedInput(body.question, 'Question') + const questionId = `input-${randomBytes(8).toString('hex')}` + transition(task, 'blocked', actor, { questionId, by: 'input requested' }) + task.waitingFor = 'input' + task.inputRequest = { id: questionId, question, requestedAt: task.updatedAt } + await save() + return view(task) + } + case 'task-answer-input': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + if (!waitingForInput(task) || typeof body.questionId !== 'string' || body.questionId !== task.inputRequest?.id) throw stale('This is not the current input request') + if (task.approvalId || task.inputApprovalIds?.length) throw stale('Approval is still required before resuming') + const answer = selectedInput(body.answer, 'Answer') + transition(task, 'working', actor, { questionId: body.questionId, by: 'input answered' }) + task.inputRequest = { ...task.inputRequest, answer, answeredAt: task.updatedAt } + await save() + return view(task) + } case 'task-accept-result': { const task = find(body.id) if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) diff --git a/core/test/tasks.test.js b/core/test/tasks.test.js index ace45e73..c725a843 100644 --- a/core/test/tasks.test.js +++ b/core/test/tasks.test.js @@ -356,3 +356,217 @@ test('a grantee may create a task on a granted child, not on an ungranted siblin assert.equal(onUngranted.ok, false, JSON.stringify(onUngranted)) assert.equal(onUngranted.error.code, 'bot/forbidden') }) + +test('restored input holds keep Bot-owner approval distinct from the task requester answer', async t => { + let app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const bob = await app.signIn('Bob', alice.sessionToken) + await app.fb.grants.set('s', [{ grantee: bob.memberId }]) + const id = await workingInputTask(app, bob.sessionToken, 's') + const waiting = await app.call('task-wait-input', { id, question: 'Question for requester' }, bob.sessionToken) + assert.equal(waiting.ok, true, JSON.stringify(waiting)) + const questionId = waiting.value.inputRequest.id + const gate = () => app.fb.bot.send.use({ name: 'input-gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'test approval' } : undefined }) + gate() + const held = await app.fb.bot.send({ from: 's', to: 'a', text: 'held', meta: { hold: true, taskId: id } }) + assert.equal(held.action, 'hold') + const home = app.home + await app.stop({ keepHome: true }) + app = await start(t, { home }) + gate() + assert.deepEqual((await statusOf(app.call, bob.sessionToken, id)).inputApprovalIds, [held.approvalId]) + const wrongDecision = await app.call('flybot/decide', { id: held.approvalId, decision: 'approve' }, bob.sessionToken) + assert.equal(wrongDecision.ok, false) + assert.equal(wrongDecision.error.code, 'bot/forbidden') + const early = await app.call('task-answer-input', { id, questionId, answer: 'Early answer' }, bob.sessionToken) + assert.equal(early.ok, false) + assert.equal(early.error.code, 'bot/stale') + assert.equal((await app.call('flybot/decide', { id: held.approvalId, decision: 'approve' }, alice.sessionToken)).ok, true) + const wrongAnswer = await app.call('task-answer-input', { id, questionId, answer: 'Bot owner answer', actorId: bob.memberId }, alice.sessionToken) + assert.equal(wrongAnswer.ok, false) + assert.equal(wrongAnswer.error.code, 'bot/forbidden') + const answer = await app.call('task-answer-input', { id, questionId, answer: 'Requester answer' }, bob.sessionToken) + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.status, 'working') +}) + +test('racing input requests and duplicate answers record one question and one answer', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const before = await statusOf(app.call, alice.sessionToken, id) + const requests = await Promise.all(['First question', 'Second question'].map(question => app.call('task-wait-input', { id, question }, alice.sessionToken))) + assert.equal(requests.filter(r => r.ok).length, 1) + assert.equal(requests.find(r => !r.ok).error.code, 'bot/stale') + const questionId = requests.find(r => r.ok).value.inputRequest.id + const answers = await Promise.all(['First answer', 'Second answer'].map(answer => app.call('task-answer-input', { id, questionId, answer }, alice.sessionToken))) + assert.equal(answers.filter(r => r.ok).length, 1) + assert.equal(answers.find(r => !r.ok).error.code, 'bot/stale') + const after = await statusOf(app.call, alice.sessionToken, id) + assert.equal(after.actors.length, before.actors.length + 2) + assert.equal(after.status, 'working') +}) + +async function workingInputTask(app, token, botId = 'a') { + const made = await app.createTask(token, { botId }) + assert.equal(made.ok, true, JSON.stringify(made)) + for (const endpoint of ['task-accept', 'task-start']) { + assert.equal((await app.call(endpoint, { id: made.value.id }, token)).ok, true) + } + return made.value.id +} + +test('task input waits survive restart, accept only the current question, and dispatch no prompt', async t => { + let app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const waiting = await app.call('task-wait-input', { id, question: 'Must retries stop after cancellation?' }, alice.sessionToken) + assert.equal(waiting.ok, true, JSON.stringify(waiting)) + assert.equal(waiting.value.status, 'blocked') + assert.equal(waiting.value.waitingFor, 'input') + const bypass = await app.call('task-start', { id }, alice.sessionToken) + assert.equal(bypass.ok, false) + assert.equal(bypass.error.code, 'bot/stale') + const questionId = waiting.value.inputRequest.id + assert.match(questionId, /^input-[0-9a-f]{16}$/) + const home = app.home + await app.stop({ keepHome: true }) + app = await start(t, { home }) + assert.deepEqual((await statusOf(app.call, alice.sessionToken, id)).inputRequest, waiting.value.inputRequest) + const calls = app.agentOf('a').calls.length + const answer = await app.call('task-answer-input', { id, questionId, answer: 'Yes, before each retry.' }, alice.sessionToken) + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.status, 'working') + assert.equal(answer.value.waitingFor, undefined) + assert.equal(answer.value.inputRequest.answer, 'Yes, before each retry.') + assert.equal(answer.value.actors.at(-1).questionId, questionId) + assert.equal(app.agentOf('a').calls.length, calls) + await app.stop({ keepHome: true }) + app = await start(t, { home }) + assert.deepEqual((await statusOf(app.call, alice.sessionToken, id)).inputRequest, answer.value.inputRequest) + const next = await app.call('task-wait-input', { id, question: 'How many attempts?' }, alice.sessionToken) + assert.equal(next.ok, true) + assert.notEqual(next.value.inputRequest.id, questionId) + assert.equal(next.value.inputRequest.answer, undefined) + const stale = await app.call('task-answer-input', { id, questionId, answer: 'Old answer' }, alice.sessionToken) + assert.equal(stale.ok, false) + assert.equal(stale.error.code, 'bot/stale') + const cancelled = await app.call('task-cancel', { id }, alice.sessionToken) + assert.equal(cancelled.ok, true) + assert.equal(cancelled.value.waitingFor, undefined) + assert.equal(cancelled.value.inputRequest.question, 'How many attempts?') + const late = await app.call('task-answer-input', { id, questionId: next.value.inputRequest.id, answer: 'Late' }, alice.sessionToken) + assert.equal(late.ok, false) + assert.equal(late.error.code, 'bot/stale') +}) + +test('input actions reject forged/wrong/revoked actors and invalid selected text', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const bob = await app.signIn('Bob', alice.sessionToken) + const id = await workingInputTask(app, alice.sessionToken) + for (const token of [bob.sessionToken, undefined]) { + const denied = await app.call('task-wait-input', { id, question: 'Question', actorId: alice.memberId }, token) + assert.equal(denied.ok, false) + assert.equal(denied.error.code, token ? 'bot/forbidden' : 'bot/unauthorized') + } + for (const question of ['', ' ', null, 123, 'x'.repeat(2001)]) { + const invalid = await app.call('task-wait-input', { id, question }, alice.sessionToken) + assert.equal(invalid.ok, false) + assert.equal(invalid.error.code, 'bot/invalid') + assert.equal((await statusOf(app.call, alice.sessionToken, id)).status, 'working') + } + const waiting = await app.call('task-wait-input', { id, question: 'Question' }, alice.sessionToken) + const questionId = waiting.value.inputRequest.id + const foreign = await app.call('task-answer-input', { id, questionId, answer: 'Forged', requesterId: alice.memberId, actorId: alice.memberId }, bob.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden') + for (const answer of ['', ' ', null, 123, 'x'.repeat(2001)]) { + const invalid = await app.call('task-answer-input', { id, questionId, answer }, alice.sessionToken) + assert.equal(invalid.ok, false) + assert.equal(invalid.error.code, 'bot/invalid') + } + await app.call('auth-revoke', {}, alice.sessionToken) + const revoked = await app.call('task-answer-input', { id, questionId, answer: 'Revoked' }, alice.sessionToken) + assert.equal(revoked.ok, false) + assert.equal(revoked.error.code, 'bot/unauthorized') +}) + +test('input actions cannot replace or clear an approval-only task wait', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + app.fb.bot.send.use({ name: 'input-gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'test approval' } : undefined }) + const held = await app.fb.bot.send({ from: 'a', to: 's', text: 'held', meta: { hold: true, taskId: id } }) + assert.equal(held.action, 'hold') + for (const [endpoint, body] of [['task-start', {}], ['task-wait-input', { question: 'Question' }], ['task-answer-input', { questionId: 'invented', answer: 'Answer' }]]) { + const denied = await app.call(endpoint, { id, ...body }, alice.sessionToken) + assert.equal(denied.ok, false) + assert.equal(denied.error.code, 'bot/stale') + } + assert.equal(app.fb.approvals.get(held.approvalId).status, 'pending') + assert.equal((await statusOf(app.call, alice.sessionToken, id)).approvalId, held.approvalId) +}) + +test('all holds during an input wait stay on that task; approval does not answer its question', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const sibling = await workingInputTask(app, alice.sessionToken) + const waiting = await app.call('task-wait-input', { id, question: 'Which retry limit?' }, alice.sessionToken) + assert.equal(waiting.ok, true, JSON.stringify(waiting)) + const questionId = waiting.value.inputRequest.id + app.fb.bot.send.use({ name: 'input-gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'test approval' } : undefined }) + const holds = [] + for (const text of ['first held send', 'second held send']) { + const held = await app.fb.bot.send({ from: 'a', to: 's', text, meta: { hold: true, taskId: id } }) + assert.equal(held.action, 'hold') + holds.push(held.approvalId) + } + assert.deepEqual((await statusOf(app.call, alice.sessionToken, id)).inputApprovalIds, holds) + assert.equal((await statusOf(app.call, alice.sessionToken, sibling)).status, 'working') + for (const approvalId of holds) { + const denied = await app.call('task-answer-input', { id, questionId, answer: 'Three' }, alice.sessionToken) + assert.equal(denied.ok, false) + assert.equal(denied.error.code, 'bot/stale') + const approved = await app.call('flybot/decide', { id: approvalId, decision: 'approve' }, alice.sessionToken) + assert.equal(approved.ok, true, JSON.stringify(approved)) + assert.equal((await statusOf(app.call, alice.sessionToken, id)).status, 'blocked') + } + const resumed = await app.call('task-answer-input', { id, questionId, answer: 'Three' }, alice.sessionToken) + assert.equal(resumed.ok, true, JSON.stringify(resumed)) + assert.equal(resumed.value.status, 'working') + assert.equal(resumed.value.approvalId, undefined) + assert.equal(resumed.value.inputApprovalIds, undefined) +}) + +test('approval rejection and Bot deletion end input waits; late answers cannot revive them', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const waiting = await app.call('task-wait-input', { id, question: 'Question' }, alice.sessionToken) + assert.equal(waiting.ok, true, JSON.stringify(waiting)) + app.fb.bot.send.use({ name: 'input-gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'test approval' } : undefined }) + const held = await app.fb.bot.send({ from: 'a', to: 's', text: 'held', meta: { hold: true, taskId: id } }) + assert.equal((await app.call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken)).ok, true) + const cancelled = await statusOf(app.call, alice.sessionToken, id) + assert.equal(cancelled.status, 'cancelled') + assert.equal(cancelled.waitingFor, undefined) + const other = await workingInputTask(app, alice.sessionToken, 's') + const question = await app.call('task-wait-input', { id: other, question: 'Another question' }, alice.sessionToken) + assert.equal(question.ok, true) + assert.equal((await app.call('delete-bot', { id: 's' }, alice.sessionToken)).ok, true) + assert.equal((await statusOf(app.call, alice.sessionToken, other)).status, 'failed') + for (const [taskId, q] of [[id, waiting.value.inputRequest.id], [other, question.value.inputRequest.id]]) { + const late = await app.call('task-answer-input', { id: taskId, questionId: q, answer: 'Late' }, alice.sessionToken) + assert.equal(late.ok, false) + assert.equal(late.error.code, 'bot/stale') + } +}) diff --git a/docs/interop/opencode-pilot.md b/docs/interop/opencode-pilot.md index 53872c79..52f2ccb7 100644 --- a/docs/interop/opencode-pilot.md +++ b/docs/interop/opencode-pilot.md @@ -139,7 +139,7 @@ facets alongside it, not invented values written into `task.status`. | Execution observation | FlyBot projection and required evidence | | --- | --- | | Request enrolled / execution starts | `open → claimed → working` only through the host-authorized seam. Adapter receipt alone does not claim a task. | -| Waiting for a selected input | `blocked`, reason `input`; correlate the question. An authorized answer resumes `working`. This reason/transition seam is new work. | +| Waiting for a selected input | `blocked`, `waitingFor: 'input'`, with the host's current question ID. Requester-only `task-wait-input` / `task-answer-input` bookkeeping exists (#207); an answer resumes `working` only after separate task approvals clear. Automatic adapter routing/authentication is still new work. | | Waiting for outbound approval | `blocked`, reason `approval`; preserve each layer's approval ID. Local approval does not clear a separate server hold. | | Unexpected tool permission request | Deny in the no-tools pilot; report the unsupported action and stop the turn. Never auto-approve or relay it as owner consent. | | Runtime idle | Observation only. Idle can mean waiting, failed, aborted or finished; it is not completion evidence. | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 14252a81..cc2699da 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -56,6 +56,31 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId` stamped on the send, matching origin when the Bot has several working tasks) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled. +### Task input waits + +`task-wait-input { id, question }` is a requester-only bookkeeping action on a +working task (#207). It records `blocked`, `waitingFor: 'input'` and a fresh +`inputRequest { id, question, requestedAt }`. `task-answer-input { id, questionId, +answer }` requires that exact current question, the same requester and no pending +task approvals before returning to `working`. Question/answer must be nonblank +strings of at most 2,000 characters. Only the latest selected question/answer +snapshot is kept, with actor entries linking its question ID; chat history is not +copied. The wait and snapshot survive restart. + +`task-start` starts a claimed task only. It cannot resume a blocked task; approval +decisions and matching input answers are the respective resume paths. An +approval-only wait cannot be replaced by an input request. Explicitly stamped +`meta.taskId` sends may still link an input-waiting task; untargeted sends do not +guess that association. Such holds accumulate in `inputApprovalIds`, preserving +all outstanding holds while a sibling task stays untouched. Each approval clears +only its own ID; approval does not answer the question. Any rejection cancels the +task. Cancellation/Bot deletion clear active input/approval-wait markers while +retaining the selected input snapshot; late answers cannot revive a terminal task. + +These endpoints do not prompt, deliver a clarification, resume an agent or grant +execution/tool permissions. Automatic exchange wiring, runtime-scoped observation +credentials and team mode remain separate work under #55/#198. + ### Inbound shared rooms - Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. A leave envelope, hide, or grant revoke marks it left/hidden. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index 2e80fe96..bf79dad1 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -17,6 +17,12 @@ This page covers the pin, the patch format and the core's dev tooling. The gener ## Owns +The mock smoke (`core/dev/smoke.mjs`) includes two API-only task clarification +steps: persist an input wait, then answer its current question (#207). Both must +make zero mock-model calls. They exercise the real DSH channel while the focused +task suite covers authorization, restart, stale answers and independent approval +holds. Task bookkeeping does not imply runtime execution or tool permission. + - `core/dev/` — harness packages, mock model, test bench, smoke test. - Pin (`ds-bot.pin.json`), ordered patches and whole added files (`patches/ds-bot/`). diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 63de50fe..559ac445 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -111,6 +111,20 @@ Private context crosses owners only through an explicit, owner-granted share; ro `state.tasks` (`core/src/host/tasks.js`): a request becomes a tracked task — `open → claimed → working → blocked ⇄ working → result-ready → accepted`, `cancelled`/`failed` terminal, `accepted` only from `result-ready`. Only the requester's session moves their task (`bot/forbidden` otherwise). Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough — a granted child can take a handoff within its authority. Outgoing holds stay the owner's: a held send (#52) blocks **that** task (`meta.taskId` stamped on the send from the working task — origin session/room when the Bot has several, else the latest — or an explicit id) until that owner decides. `result-ready` is protected from prune while it waits for acceptance. Task channel names are an explicit allow-list (`tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel`). Records carry per-status actor entries, pointers to the originating conversation, and a summary — never private transcripts. The in-room task view and audit trail are #57's; clarification (ask the requester) and `workBotId` (the specialist on the exchange) wait on #55's exchange wiring. Team mode stays disabled. +### Requester-controlled input-wait bookkeeping (#207) + +The host task API has `task-wait-input` and `task-answer-input`, both checked +against the task's server-derived requester. They persist one bounded selected +question/answer snapshot and a current question ID, not conversation history. +Answering requires the current ID and no pending task approvals. `task-start` +only starts claimed work; it cannot bypass a blocked input or approval wait. +Explicitly task-tagged holds that arrive during an input wait stay separately +tracked, including across restart; approval clears no unanswered question. + +This is bookkeeping only: no automatic question delivery, model prompt, external +runtime authentication or cross-owner execution is added. The #55 exchange wiring +and team-mode gates remain unsatisfied. See [core-host](core-host.md#task-input-waits). + ## Identity model | Relation | Rule | diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index d847c85b..b31b4650 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -419,7 +419,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,7 +319,51 @@ export function install(rt) { +@@ -319,7 +319,53 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -430,6 +430,8 @@ diff --git a/src/host/channel.js b/src/host/channel.js + case 'task-return': + case 'task-accept-result': + case 'task-cancel': ++ case 'task-wait-input': ++ case 'task-answer-input': + await rt.load() + return ok(await rt.taskEndpoint(endpoint, body, actor)) + case 'share-brief': diff --git a/patches/ds-bot/0007-version-and-tooling.patch b/patches/ds-bot/0007-version-and-tooling.patch index 41c95f31..48abff19 100644 --- a/patches/ds-bot/0007-version-and-tooling.patch +++ b/patches/ds-bot/0007-version-and-tooling.patch @@ -1,5 +1,31 @@ FlyBot patch 0007-version-and-tooling on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/). +diff --git a/dev/smoke.mjs b/dev/smoke.mjs +--- a/dev/smoke.mjs ++++ b/dev/smoke.mjs +@@ -358,6 +358,22 @@ await step('detached capabilities disappear from Chief', () => send(chief().id, + return expect(report?.endsWith(seesNothing), `Chief reported ${report}`) + }) + ++let inputTaskId ++const inputWait = await step('task records an input wait without a model call', async () => { ++ const task = await api('task-create', { botId: chief().id, title: 'Input smoke', criteria: 'Record selected input' }) ++ inputTaskId = task.id ++ await api('task-accept', { id: task.id }) ++ await api('task-start', { id: task.id }) ++ return api('task-wait-input', { id: task.id, question: 'How many retry attempts?' }) ++}, (value, calls) => expect(value.status === 'blocked' && value.waitingFor === 'input' && typeof value.inputRequest?.id === 'string', 'expected a current input question') ++ ?? expect(calls.length === 0, 'task bookkeeping must not call the model')) ++ ++await step('matching task input resumes bookkeeping without a model call', () => api('task-answer-input', { ++ id: inputTaskId, questionId: inputWait?.inputRequest?.id, answer: 'Three attempts, checking cancellation each time.', ++}), (value, calls) => expect(value.status === 'working' && value.waitingFor === undefined && value.inputRequest?.answeredAt !== undefined, 'expected answered input and working status') ++ ?? expect(calls.length === 0, 'answering task input must not call the model')) ++if (inputTaskId) await api('task-cancel', { id: inputTaskId }) ++ + if (outFile) writeFileSync(outFile, `${JSON.stringify(transcript, null, 2)}\n`) + console.log(failures.length === 0 ? `\nsmoke: all ${transcript.length} steps passed` : `\nsmoke: ${failures.length} failed\n${failures.join('\n')}`) + process.exit(failures.length === 0 ? 0 : 1) diff --git a/package-lock.json b/package-lock.json --- a/package-lock.json +++ b/package-lock.json diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index ddb529c2..eef95f56 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -53,6 +53,7 @@ const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss // here keeps a future `task-*` from auto-allowing for any signed-in actor (#111). const TASK_ENDPOINTS = new Set([ 'tasks', 'task-create', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-cancel', + 'task-wait-input', 'task-answer-input', ]) const deny = (code, message) => ({ ok: false, code, message }) diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index 7a7af572..e0c34b89 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -33,6 +33,13 @@ const KEPT = 200 const stale = message => Object.assign(new Error(message), { code: 'bot/stale' }) const forbidden = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) +const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) +const waitingForInput = task => task.status === 'blocked' && task.waitingFor === 'input' +const canLinkSend = task => task.status === 'working' || waitingForInput(task) +const selectedInput = (value, name) => { + if (typeof value !== 'string' || !value.trim() || value.length > 2000) throw invalid(`${name} must contain 1–2000 characters`) + return value +} export function install(rt) { const { state } = rt @@ -71,11 +78,19 @@ export function install(rt) { && live.some(p => p.kind === 'bot' && !p.remote && p.id === bot.id) } + function record(task, actor, extra) { + task.updatedAt = Date.now() + task.actors.push({ status: task.status, at: task.updatedAt, ...(actor?.memberId ? { memberId: actor.memberId } : {}), ...extra }) + } function transition(task, to, actor, extra) { if (!TRANSITIONS[task.status]?.includes(to)) throw stale(`a task that is ${task.status} cannot become ${to}`) + if (waitingForInput(task) && to !== 'blocked') { + delete task.waitingFor + delete task.inputApprovalIds + delete task.approvalId + } task.status = to - task.updatedAt = Date.now() - task.actors.push({ status: to, at: task.updatedAt, ...(actor?.memberId ? { memberId: actor.memberId } : {}), ...extra }) + record(task, actor, extra) } const view = task => ({ ...task, actors: [...task.actors ?? []] }) @@ -90,7 +105,8 @@ export function install(rt) { // Name the working task a send belongs to so a hold can block that one (#111). // Prefer an already-stamped `meta.taskId`; else the Bot's only working task; // else the one whose origin matches this send's session/room; else the most - // recently started. Clarification / workBotId still wait on #55. + // recently started. An explicit id also links a task waiting for input; do not + // infer that binding from an unrelated Bot conversation. workBotId waits on #55. rt.taskIdForSend = (msg) => { const from = msg?.from if (typeof from !== 'string') return undefined @@ -98,7 +114,7 @@ export function install(rt) { const given = typeof msg?.meta?.taskId === 'string' ? msg.meta.taskId : undefined if (given) { const named = store()[given] - if (named && named.status === 'working' && ofSender(named)) return given + if (named && canLinkSend(named) && ofSender(named)) return given } const working = Object.values(store()).filter(task => task.status === 'working' && ofSender(task)) if (working.length === 0) return undefined @@ -120,10 +136,16 @@ export function install(rt) { const named = namedId ? store()[namedId] : undefined const ofSender = task => task.ownerBotId === msg.from || task.workBotId === msg.from const working = Object.values(store()).filter(task => task.status === 'working' && ofSender(task)) - const targets = named && named.status === 'working' && ofSender(named) + const targets = named && canLinkSend(named) && ofSender(named) ? [named] : working.length === 1 ? working : [] for (const task of targets) { + if (waitingForInput(task)) { + task.inputApprovalIds = [...new Set([...(task.inputApprovalIds ?? []), approval.id])] + task.approvalId = task.inputApprovalIds[0] + record(task, null, { approvalId: approval.id, by: 'approval while awaiting input' }) + continue + } task.approvalId = approval.id transition(task, 'blocked', null, { approvalId: approval.id }) } @@ -132,6 +154,17 @@ export function install(rt) { // The owner's decision on that approval resumes the task — or, on reject, ends it. rt.taskDecided = (approval, decision) => { for (const task of Object.values(store())) { + if (waitingForInput(task) && task.inputApprovalIds?.includes(approval.id)) { + if (decision !== 'approve') { + transition(task, 'cancelled', { memberId: approval.ownerId }, { approvalId: approval.id, by: 'owner decision' }) + continue + } + task.inputApprovalIds = task.inputApprovalIds.filter(id => id !== approval.id) + if (task.inputApprovalIds.length) task.approvalId = task.inputApprovalIds[0] + else delete task.approvalId + record(task, { memberId: approval.ownerId }, { approvalId: approval.id, by: 'approved; input still required' }) + continue + } if (task.status !== 'blocked' || task.approvalId !== approval.id) continue delete task.approvalId transition(task, decision === 'approve' ? 'working' : 'cancelled', { memberId: approval.ownerId }, { by: 'owner decision' }) @@ -192,6 +225,7 @@ export function install(rt) { const task = find(body.id) if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) requireRequester(task, actor) + if (task.status !== 'claimed') throw stale('Only a claimed task can start; blocked tasks must clear their input or approval wait') transition(task, 'working', actor) await save() return view(task) @@ -205,6 +239,31 @@ export function install(rt) { await save() return view(task) } + case 'task-wait-input': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + if (task.status !== 'working' || task.approvalId) throw stale('Only a working task without an approval wait may request input') + const question = selectedInput(body.question, 'Question') + const questionId = `input-${randomBytes(8).toString('hex')}` + transition(task, 'blocked', actor, { questionId, by: 'input requested' }) + task.waitingFor = 'input' + task.inputRequest = { id: questionId, question, requestedAt: task.updatedAt } + await save() + return view(task) + } + case 'task-answer-input': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + if (!waitingForInput(task) || typeof body.questionId !== 'string' || body.questionId !== task.inputRequest?.id) throw stale('This is not the current input request') + if (task.approvalId || task.inputApprovalIds?.length) throw stale('Approval is still required before resuming') + const answer = selectedInput(body.answer, 'Answer') + transition(task, 'working', actor, { questionId: body.questionId, by: 'input answered' }) + task.inputRequest = { ...task.inputRequest, answer, answeredAt: task.updatedAt } + await save() + return view(task) + } case 'task-accept-result': { const task = find(body.id) if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) diff --git a/patches/ds-bot/files/test/tasks.test.js b/patches/ds-bot/files/test/tasks.test.js index ace45e73..c725a843 100644 --- a/patches/ds-bot/files/test/tasks.test.js +++ b/patches/ds-bot/files/test/tasks.test.js @@ -356,3 +356,217 @@ test('a grantee may create a task on a granted child, not on an ungranted siblin assert.equal(onUngranted.ok, false, JSON.stringify(onUngranted)) assert.equal(onUngranted.error.code, 'bot/forbidden') }) + +test('restored input holds keep Bot-owner approval distinct from the task requester answer', async t => { + let app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const bob = await app.signIn('Bob', alice.sessionToken) + await app.fb.grants.set('s', [{ grantee: bob.memberId }]) + const id = await workingInputTask(app, bob.sessionToken, 's') + const waiting = await app.call('task-wait-input', { id, question: 'Question for requester' }, bob.sessionToken) + assert.equal(waiting.ok, true, JSON.stringify(waiting)) + const questionId = waiting.value.inputRequest.id + const gate = () => app.fb.bot.send.use({ name: 'input-gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'test approval' } : undefined }) + gate() + const held = await app.fb.bot.send({ from: 's', to: 'a', text: 'held', meta: { hold: true, taskId: id } }) + assert.equal(held.action, 'hold') + const home = app.home + await app.stop({ keepHome: true }) + app = await start(t, { home }) + gate() + assert.deepEqual((await statusOf(app.call, bob.sessionToken, id)).inputApprovalIds, [held.approvalId]) + const wrongDecision = await app.call('flybot/decide', { id: held.approvalId, decision: 'approve' }, bob.sessionToken) + assert.equal(wrongDecision.ok, false) + assert.equal(wrongDecision.error.code, 'bot/forbidden') + const early = await app.call('task-answer-input', { id, questionId, answer: 'Early answer' }, bob.sessionToken) + assert.equal(early.ok, false) + assert.equal(early.error.code, 'bot/stale') + assert.equal((await app.call('flybot/decide', { id: held.approvalId, decision: 'approve' }, alice.sessionToken)).ok, true) + const wrongAnswer = await app.call('task-answer-input', { id, questionId, answer: 'Bot owner answer', actorId: bob.memberId }, alice.sessionToken) + assert.equal(wrongAnswer.ok, false) + assert.equal(wrongAnswer.error.code, 'bot/forbidden') + const answer = await app.call('task-answer-input', { id, questionId, answer: 'Requester answer' }, bob.sessionToken) + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.status, 'working') +}) + +test('racing input requests and duplicate answers record one question and one answer', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const before = await statusOf(app.call, alice.sessionToken, id) + const requests = await Promise.all(['First question', 'Second question'].map(question => app.call('task-wait-input', { id, question }, alice.sessionToken))) + assert.equal(requests.filter(r => r.ok).length, 1) + assert.equal(requests.find(r => !r.ok).error.code, 'bot/stale') + const questionId = requests.find(r => r.ok).value.inputRequest.id + const answers = await Promise.all(['First answer', 'Second answer'].map(answer => app.call('task-answer-input', { id, questionId, answer }, alice.sessionToken))) + assert.equal(answers.filter(r => r.ok).length, 1) + assert.equal(answers.find(r => !r.ok).error.code, 'bot/stale') + const after = await statusOf(app.call, alice.sessionToken, id) + assert.equal(after.actors.length, before.actors.length + 2) + assert.equal(after.status, 'working') +}) + +async function workingInputTask(app, token, botId = 'a') { + const made = await app.createTask(token, { botId }) + assert.equal(made.ok, true, JSON.stringify(made)) + for (const endpoint of ['task-accept', 'task-start']) { + assert.equal((await app.call(endpoint, { id: made.value.id }, token)).ok, true) + } + return made.value.id +} + +test('task input waits survive restart, accept only the current question, and dispatch no prompt', async t => { + let app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const waiting = await app.call('task-wait-input', { id, question: 'Must retries stop after cancellation?' }, alice.sessionToken) + assert.equal(waiting.ok, true, JSON.stringify(waiting)) + assert.equal(waiting.value.status, 'blocked') + assert.equal(waiting.value.waitingFor, 'input') + const bypass = await app.call('task-start', { id }, alice.sessionToken) + assert.equal(bypass.ok, false) + assert.equal(bypass.error.code, 'bot/stale') + const questionId = waiting.value.inputRequest.id + assert.match(questionId, /^input-[0-9a-f]{16}$/) + const home = app.home + await app.stop({ keepHome: true }) + app = await start(t, { home }) + assert.deepEqual((await statusOf(app.call, alice.sessionToken, id)).inputRequest, waiting.value.inputRequest) + const calls = app.agentOf('a').calls.length + const answer = await app.call('task-answer-input', { id, questionId, answer: 'Yes, before each retry.' }, alice.sessionToken) + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.status, 'working') + assert.equal(answer.value.waitingFor, undefined) + assert.equal(answer.value.inputRequest.answer, 'Yes, before each retry.') + assert.equal(answer.value.actors.at(-1).questionId, questionId) + assert.equal(app.agentOf('a').calls.length, calls) + await app.stop({ keepHome: true }) + app = await start(t, { home }) + assert.deepEqual((await statusOf(app.call, alice.sessionToken, id)).inputRequest, answer.value.inputRequest) + const next = await app.call('task-wait-input', { id, question: 'How many attempts?' }, alice.sessionToken) + assert.equal(next.ok, true) + assert.notEqual(next.value.inputRequest.id, questionId) + assert.equal(next.value.inputRequest.answer, undefined) + const stale = await app.call('task-answer-input', { id, questionId, answer: 'Old answer' }, alice.sessionToken) + assert.equal(stale.ok, false) + assert.equal(stale.error.code, 'bot/stale') + const cancelled = await app.call('task-cancel', { id }, alice.sessionToken) + assert.equal(cancelled.ok, true) + assert.equal(cancelled.value.waitingFor, undefined) + assert.equal(cancelled.value.inputRequest.question, 'How many attempts?') + const late = await app.call('task-answer-input', { id, questionId: next.value.inputRequest.id, answer: 'Late' }, alice.sessionToken) + assert.equal(late.ok, false) + assert.equal(late.error.code, 'bot/stale') +}) + +test('input actions reject forged/wrong/revoked actors and invalid selected text', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const bob = await app.signIn('Bob', alice.sessionToken) + const id = await workingInputTask(app, alice.sessionToken) + for (const token of [bob.sessionToken, undefined]) { + const denied = await app.call('task-wait-input', { id, question: 'Question', actorId: alice.memberId }, token) + assert.equal(denied.ok, false) + assert.equal(denied.error.code, token ? 'bot/forbidden' : 'bot/unauthorized') + } + for (const question of ['', ' ', null, 123, 'x'.repeat(2001)]) { + const invalid = await app.call('task-wait-input', { id, question }, alice.sessionToken) + assert.equal(invalid.ok, false) + assert.equal(invalid.error.code, 'bot/invalid') + assert.equal((await statusOf(app.call, alice.sessionToken, id)).status, 'working') + } + const waiting = await app.call('task-wait-input', { id, question: 'Question' }, alice.sessionToken) + const questionId = waiting.value.inputRequest.id + const foreign = await app.call('task-answer-input', { id, questionId, answer: 'Forged', requesterId: alice.memberId, actorId: alice.memberId }, bob.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden') + for (const answer of ['', ' ', null, 123, 'x'.repeat(2001)]) { + const invalid = await app.call('task-answer-input', { id, questionId, answer }, alice.sessionToken) + assert.equal(invalid.ok, false) + assert.equal(invalid.error.code, 'bot/invalid') + } + await app.call('auth-revoke', {}, alice.sessionToken) + const revoked = await app.call('task-answer-input', { id, questionId, answer: 'Revoked' }, alice.sessionToken) + assert.equal(revoked.ok, false) + assert.equal(revoked.error.code, 'bot/unauthorized') +}) + +test('input actions cannot replace or clear an approval-only task wait', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + app.fb.bot.send.use({ name: 'input-gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'test approval' } : undefined }) + const held = await app.fb.bot.send({ from: 'a', to: 's', text: 'held', meta: { hold: true, taskId: id } }) + assert.equal(held.action, 'hold') + for (const [endpoint, body] of [['task-start', {}], ['task-wait-input', { question: 'Question' }], ['task-answer-input', { questionId: 'invented', answer: 'Answer' }]]) { + const denied = await app.call(endpoint, { id, ...body }, alice.sessionToken) + assert.equal(denied.ok, false) + assert.equal(denied.error.code, 'bot/stale') + } + assert.equal(app.fb.approvals.get(held.approvalId).status, 'pending') + assert.equal((await statusOf(app.call, alice.sessionToken, id)).approvalId, held.approvalId) +}) + +test('all holds during an input wait stay on that task; approval does not answer its question', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const sibling = await workingInputTask(app, alice.sessionToken) + const waiting = await app.call('task-wait-input', { id, question: 'Which retry limit?' }, alice.sessionToken) + assert.equal(waiting.ok, true, JSON.stringify(waiting)) + const questionId = waiting.value.inputRequest.id + app.fb.bot.send.use({ name: 'input-gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'test approval' } : undefined }) + const holds = [] + for (const text of ['first held send', 'second held send']) { + const held = await app.fb.bot.send({ from: 'a', to: 's', text, meta: { hold: true, taskId: id } }) + assert.equal(held.action, 'hold') + holds.push(held.approvalId) + } + assert.deepEqual((await statusOf(app.call, alice.sessionToken, id)).inputApprovalIds, holds) + assert.equal((await statusOf(app.call, alice.sessionToken, sibling)).status, 'working') + for (const approvalId of holds) { + const denied = await app.call('task-answer-input', { id, questionId, answer: 'Three' }, alice.sessionToken) + assert.equal(denied.ok, false) + assert.equal(denied.error.code, 'bot/stale') + const approved = await app.call('flybot/decide', { id: approvalId, decision: 'approve' }, alice.sessionToken) + assert.equal(approved.ok, true, JSON.stringify(approved)) + assert.equal((await statusOf(app.call, alice.sessionToken, id)).status, 'blocked') + } + const resumed = await app.call('task-answer-input', { id, questionId, answer: 'Three' }, alice.sessionToken) + assert.equal(resumed.ok, true, JSON.stringify(resumed)) + assert.equal(resumed.value.status, 'working') + assert.equal(resumed.value.approvalId, undefined) + assert.equal(resumed.value.inputApprovalIds, undefined) +}) + +test('approval rejection and Bot deletion end input waits; late answers cannot revive them', async t => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const waiting = await app.call('task-wait-input', { id, question: 'Question' }, alice.sessionToken) + assert.equal(waiting.ok, true, JSON.stringify(waiting)) + app.fb.bot.send.use({ name: 'input-gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'test approval' } : undefined }) + const held = await app.fb.bot.send({ from: 'a', to: 's', text: 'held', meta: { hold: true, taskId: id } }) + assert.equal((await app.call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken)).ok, true) + const cancelled = await statusOf(app.call, alice.sessionToken, id) + assert.equal(cancelled.status, 'cancelled') + assert.equal(cancelled.waitingFor, undefined) + const other = await workingInputTask(app, alice.sessionToken, 's') + const question = await app.call('task-wait-input', { id: other, question: 'Another question' }, alice.sessionToken) + assert.equal(question.ok, true) + assert.equal((await app.call('delete-bot', { id: 's' }, alice.sessionToken)).ok, true) + assert.equal((await statusOf(app.call, alice.sessionToken, other)).status, 'failed') + for (const [taskId, q] of [[id, waiting.value.inputRequest.id], [other, question.value.inputRequest.id]]) { + const late = await app.call('task-answer-input', { id: taskId, questionId: q, answer: 'Late' }, alice.sessionToken) + assert.equal(late.ok, false) + assert.equal(late.error.code, 'bot/stale') + } +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 77204c12..19cbe588 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -51,7 +51,8 @@ "package.json", "scripts/lint.mjs", "package-lock.json", - "test/diagnostics.test.js" + "test/diagnostics.test.js", + "dev/smoke.mjs" ], "0008-client-bundle": [ "client.js"