diff --git a/changelog.d/1203-room-context.fixed.md b/changelog.d/1203-room-context.fixed.md new file mode 100644 index 000000000..287f6c4a1 --- /dev/null +++ b/changelog.d/1203-room-context.fixed.md @@ -0,0 +1 @@ +A group turn shows a character-budgeted room history, with long CI and merge essays collapsed, and a compact task-board snapshot. Assigning, claiming, and progress go through that board (overall task, subtask, assignee, column, and progress); @ only wakes a member ([#1203](https://github.com/ThinkFlowLab/FlyBot/issues/1203)). diff --git a/changelog.d/1205-board-drive.fixed.md b/changelog.d/1205-board-drive.fixed.md new file mode 100644 index 000000000..85d7de46c --- /dev/null +++ b/changelog.d/1205-board-drive.fixed.md @@ -0,0 +1 @@ +A group card wakes its assignee in that Bot's own chat. Assigning, progress, and handoff are board writes; the group stays quiet, and a Bot @ whose open card already shows the line passes ([#1205](https://github.com/ThinkFlowLab/FlyBot/issues/1205)). diff --git a/core/client.js b/core/client.js index c77fdefdf..5f357b340 100644 --- a/core/client.js +++ b/core/client.js @@ -713,6 +713,7 @@ var ZH = { "Complete task": "完成任务", "Add task": "添加任务", "Group task": "群任务", + "Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.": "派活、领活和进度都在这块看板上:一个总任务、每个部分一条子任务、负责人、列和进度。@ 只是叫醒对方。", "Part of {title}": "属于 {title}", "Task title": "任务标题", Progress: "进度", @@ -8538,6 +8539,7 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro "div", { className: "bt-board-main" }, error ? (0, import_react15.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, + lanes ? (0, import_react15.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, scope?.kind === "bot" && onCreate ? (0, import_react15.createElement)( "form", { className: "bt-board-add", onSubmit: (event) => { @@ -8691,6 +8693,7 @@ var KANBAN_CSS = ` .bt-board-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} .bt-board-drop{margin:0;padding:18px 8px;border:1px dashed var(--bt-line-2);border-radius:12px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)} .bt-board-empty{margin:0;padding:8px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-3)} +.bt-board-hint{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} .bt-board-error{margin:0;font-size:13px;line-height:18px;color:#c21d2e} .bt-board-lane{display:flex;flex-direction:column;gap:6px} .bt-board-lane-name{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3);padding:0 2px} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 035c8fa4e..7220840d4 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -654,6 +654,7 @@ const ZH = { 'Complete task': '完成任务', 'Add task': '添加任务', 'Group task': '群任务', + 'Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.': '派活、领活和进度都在这块看板上:一个总任务、每个部分一条子任务、负责人、列和进度。@ 只是叫醒对方。', 'Part of {title}': '属于 {title}', 'Task title': '任务标题', Progress: '进度', diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index 55861e5c6..8083f3d50 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -289,6 +289,7 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, rail.groups.map(row => h(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect }))), h('div', { className: 'bt-board-main' }, error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null, + lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null, scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, @@ -401,6 +402,7 @@ export const KANBAN_CSS = ` .bt-board-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} .bt-board-drop{margin:0;padding:18px 8px;border:1px dashed var(--bt-line-2);border-radius:12px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)} .bt-board-empty{margin:0;padding:8px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-3)} +.bt-board-hint{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} .bt-board-error{margin:0;font-size:13px;line-height:18px;color:#c21d2e} .bt-board-lane{display:flex;flex-direction:column;gap:6px} .bt-board-lane-name{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3);padding:0 2px} diff --git a/core/src/host/groups.js b/core/src/host/groups.js index fffeab129..df1457c55 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -30,6 +30,7 @@ import { FACE_LEAD_NOTE, FACE_LEAD_ROUTE, faceLeadIsRemote, faceMemberNote, face import { faceMentions } from './face-mentions.js' import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js' import { createRoomParticipants } from './room-participant.js' +import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, prepareRoomLine } from './room-context.js' const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 @@ -156,12 +157,17 @@ export const referenceNote = references => (references?.length : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' +// A group that has a board. Assigning, claiming, and progress are board writes. +// @Name only wakes. A face-to-face room has no board, so it keeps ROOM_REPLY. +const ROOM_REPLY_BOARD = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Assigning work, claiming it, and progress are the task board: the overall task, the subtask, the assignee, the column, and the progress, written with track_tasks. @Name only wakes a member whose card already names them; it does not assign or record progress. Do not narrate CI, merge-queue, or review status in the group. When the board already shows it, reply [PASS]. A status meant for someone else is [PASS]. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' // The turn's own closing line: the full reply rule above stands in the group's section of the system prompt. const ROOM_REPLY_NOW = 'Reply now with your message for the group, no name prefix, or [PASS] to stay silent.' // Words that address the whole room: a plain line with one of them may get an answer from each Bot (#299). const ROOM_EVERYONE = /大家|你们|各位|所有人|所有 ?[Bb]ot|每个人|每位|各自|分别|everyone|everybody|y'?all|you all|all of you|each of you|introduce yourselves/i const ROOM_REPLY_FORCED = 'This turn is the exception to the [PASS] rule: reply with your message for the group, no name prefix, short. Do not reply [PASS].' const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' +// Same wake, on a room that has a board: the call does not assign or record progress. +const ROOM_CALLS_BOARD = 'Writing "@Name" wakes that member to answer, or to do the card the board already assigned to it. It does not assign work or record progress. When it finishes that card, you answer its result. To only mention a member, write its name without "@".' const slotLabel = (slot) => { if (slot.status === 'speaking') return `${slot.name} (you, now)` @@ -283,10 +289,10 @@ export function install(rt) { const leadId = typeof rt.taskLeadOf === 'function' ? rt.taskLeadOf(room) : undefined if (!leadId) return 'This room has no task lead on the board. Do not divide the work; the user or a Main Bot picks one task lead.' if (leadId === member.id) { - return 'You are this room\'s task lead (总负责人) for the board. When the group is given a job, record one overall task with track_tasks and assign each part to the Bot it fits (assignee is that Bot\'s name). Nobody else plans. A card you assign is claimed: that Bot starts the work and does not wait for a confirmation. A question stays in this group.' + return 'You are this room\'s task lead (总负责人) for the board. When the group is given a job, assign it on the board with track_tasks: one overall task, and a subtask for each part (assignee is that Bot\'s name). Nobody else plans. A chat line does not assign. A card you assign is claimed: that Bot starts the work in its own chat and does not wait for a confirmation. Progress is that card\'s column and progress. A question a person has to decide is one short post in this group; do not @ a Bot to hand the work on.' } const name = String(rt.botOf(leadId)?.name ?? 'The task lead').replace(/[\r\n]+/g, ' ').slice(0, 80) - return `${name} is this room's task lead (总负责人) for the board: it writes the one plan and assigns the parts. Do the card it assigns you, or link your own subtask to that overall task. Do not record a new plan or assign work. Start the work; do not wait for a confirmation. A question about your card goes in your reply here, not in your own chat.` + return `${name} is this room's task lead (总负责人) for the board: it assigns the parts on the board. Claim the card it assigns you by setting its column to in-progress with track_tasks, and update that card's column and progress as the work moves. Do not record a new plan, assign work, or post a status in the group. @Name only wakes someone; it does not assign. Start the work in your own chat; do not wait for a confirmation. A question a person has to decide is one short post in this group; do not @ a Bot to hand the work on.` } // What holds for every turn of a member in this group (#894): who is here, how calls work, the @@ -299,7 +305,8 @@ export function install(rt) { const peers = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${rt.isAdminOf(room, bot.id) ? ' (admin)' : ''}`) const leads = rt.isAdminOf(room, member.id) - const members = `${isFaceRoom(room) ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}` + const face = isFaceRoom(room) + const members = `${face ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${face ? ROOM_CALLS : ROOM_CALLS_BOARD}` return [ '# This group chat', 'This conversation is where you speak in one group chat. Each turn arrives as a "[Group chat …]" message with what is new and the speaking order; the rules below hold for every turn here.', @@ -308,7 +315,7 @@ export function install(rt) { leads ? adminNote(room) : faceMemberNote(room, member, rt.botOf), taskBoardNote(room, member), routeRules(room, member), - ROOM_REPLY, + face ? ROOM_REPLY : ROOM_REPLY_BOARD, ].filter(Boolean).join('\n\n') } const roomSectionFor = (sessionId) => { @@ -343,18 +350,34 @@ export function install(rt) { // The group's standing rules are not repeated here: they are in the section above. function roomPrompt(room, member, round) { const lines = unseenLines(room, member.id) - const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) - const current = lines.filter(line => line.seq >= round.startSeq) + const earlierLines = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) + const currentLines = lines.filter(line => line.seq >= round.startSeq) + // Count caps first, then a character budget. Long status essays collapse + // inside the budget; the stored log is unchanged (#1203). + const fitted = fitRoomLines([...earlierLines, ...currentLines], { + budget: ROOM_CONTEXT_BUDGET, + render: lineText, + prepare: prepareRoomLine, + }) + const earlier = fitted.entries.filter(entry => entry.line.seq < round.startSeq) + const current = fitted.entries.filter(entry => entry.line.seq >= round.startSeq) + const show = entries => entries.map(entry => entry.text).join('\n') const spokeThisRound = (room.seen[member.id] ?? 0) >= round.startSeq const index = round.slots.findIndex(slot => slot.status === 'speaking') const before = index const after = round.slots.length - index - 1 const working = workingIn(room).filter(item => item.bot.id !== member.id) + const board = isFaceRoom(room) ? '' : formatRoomBoard(rt.state.tasks, { + roomId: room.id, + nameOf: task => rt.botOf(task.ownerBotId)?.name, + }) return [ noticeChange(room, member), - earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '', + board, + fitted.omitted > 0 ? `${fitted.omitted} older lines are not in this turn. Progress is on the task board; call read_group_chat when you need an exact earlier line.` : '', + earlier.length ? `Earlier in the group, before this round (context only):\n${show(earlier)}` : '', current.length - ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}` + ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${show(current)}` : 'Nothing new in this round since your last message.', round.shown?.includes(member.id) ? '' : referenceNote(round.references), working.length > 0 ? `Still at work on what they were asked; each posts here when done: ${working.map(item => `${item.bot.name} (${minutesSince(item.since)} min)`).join(', ')}.` : '', @@ -377,13 +400,15 @@ export function install(rt) { // already spoke; either way it goes right after slot `index`. `cap` is the caller's // per-member turn bound for the round; `by` is the calling member, if a member called. // `uncapped` names the callees that bound does not apply to (#804). - function callMembers(round, turns, index, callees, note, cap, by, uncapped = NOBODY) { + function callMembers(round, turns, index, callees, note, cap, by, uncapped = NOBODY, cue) { let next = index + 1 for (const peer of callees) { if (!uncapped.has(peer.id) && (turns.get(peer.id) ?? 0) >= cap) continue const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting') const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0] called.note = note + if (typeof cue === 'string') called.cue = cue + else if (by === undefined) delete called.cue if (by === undefined) delete called.by else called.by = by round.slots.splice(next, 0, called) @@ -524,7 +549,7 @@ export function install(rt) { if (callees.length === 0) continue const leads = rt.isAdminOf(room, call.by) const uncapped = new Set(callees.filter(bot => rt.isAdminOf(room, bot.id)).map(bot => bot.id)) - callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped) + callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped, call.text) } return round.slots.length - before } @@ -560,6 +585,13 @@ export function install(rt) { round.passed.push(member.name) continue } + // A Bot @ whose open card already shows this line is silence, not a turn (#1205). + // The slot is passed, so the report says stayed silent rather than no answer. + if (slot.by !== undefined && typeof slot.cue === 'string' && !isFaceRoom(room) && boardCoversCue(rt.state.tasks, { roomId: room.id, botId: member.id, text: slot.cue })) { + slot.status = 'passed' + round.passed.push(member.name) + continue + } slot.status = 'speaking' round.speaking = member.id round.turns += 1 @@ -635,7 +667,7 @@ export function install(rt) { // and dropping that is the failure #804 was filed for. Everyone else keeps the cap, so a chain of // Bots naming each other cannot keep a round talking. const uncapped = new Set(found.filter(other => rt.isAdminOf(room, other.id)).map(other => other.id)) - if (!round.single) callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, member.id, uncapped) + if (!round.single) callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, member.id, uncapped, typeof reply === 'string' ? reply : undefined) } } @@ -688,7 +720,7 @@ export function install(rt) { ...(references.length > 0 ? { references, shown: [] } : {}), slots: speakers.map(member => ({ id: member.id, name: member.name, status: 'waiting', - ...(named.includes(member) ? (opener.botId === undefined ? { addressed: true } : { by: opener.botId }) : {}), + ...(named.includes(member) ? (opener.botId === undefined ? { addressed: true } : { by: opener.botId, cue: opener.text ?? '' }) : {}), })), // One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop. ...(isPlainFaceLine(room, opener) ? { single: true } : {}), @@ -988,7 +1020,7 @@ export function install(rt) { // of the held post from calling them a second time. const callees = mentionsIn(text, room.members.map(rt.botOf).filter(Boolean)).found.filter(bot => bot.id !== sender.id) if (callees.length > 0) { - round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees }] + round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees, text }] extra.called = true } return true diff --git a/core/src/host/prompt.js b/core/src/host/prompt.js index 4be29552f..e6ab1fcc4 100644 --- a/core/src/host/prompt.js +++ b/core/src/host/prompt.js @@ -16,19 +16,19 @@ const TEAM_RULES = [ '- For "[Group chat …]", "[Message from …]", "[Reply from …]", or "[Continue task]" messages, do not write plans or progress updates: use tools silently, then reply with the answer. Simple answers do not need progress messages.', '- Offer choices with ask_user (a tap-to-answer card) instead of long option lists in text. Calling it ends your turn at once, so write everything you want to say before the call, in the same reply. The answer arrives as the user\'s next message.', '- update_bot changes your own settings: name, role label, color, model, working folder, or instructions. Leave out bot to change yourself. Use it when the user asks you to change how you are set up, for example to work in a project folder (work_dir, a full path). Only a Main Bot can change other Bots; when another Bot needs changing, ask a Main Bot.', - '- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. When someone there asks you to do something, do the work in that turn: the group goes on without waiting, and your final reply is posted when you finish. Any member can hand work to another with @Name, and the one that asked answers the result. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.', + '- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. When someone there asks you to do something, do the work in that turn: the group goes on without waiting, and your final reply is posted when you finish. In a group that has a task board, @Name only wakes a member; it does not assign work or record progress. Assigning, claiming, and progress there are the task board (overall task, subtask, assignee, column, and progress) through track_tasks. A face-to-face room has no board: there, @Name still gives a part. The one that asked answers the result. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.', '- When you need a key, token, or password, call request_secret: the user types it into a secure card and you never see it. Never ask the user to paste one into the chat. Use it through its environment variable ($DSH_SECRET_ in bash, $env:DSH_SECRET_ in PowerShell) and never print it. list_secrets shows which keys exist.', '- For GitHub work, use the GitHub connector tools (mcp__github__*) first. Fall back to $DSH_SECRET_GITHUB_TOKEN with gh or git in the shell only for what those tools cannot do, such as git push.', '- web_search results say which search backend answered. If web_search fails, never guess URLs or present pages you remember as search results: use a browser tool if you have one, otherwise tell the user that search is unavailable and the reason it gave.', '- Your conversations are long-lived. When one grows long, either its older messages are condensed in place into a summary you wrote (a "[Checkpoint N]" message), or it moves on to a fresh part that opens with a handoff note you wrote and your latest messages (a "[Handoff · part N]" message). What leaves your working context stays saved. When you need the exact earlier wording or a detail the summary or note does not show in full (a full path, a figure, every item of a complete list or a final summary), call read_own_chat with words to look for, or with part N to read the note that closed an earlier part; never fill such a detail in from memory. Each line it returns starts with an anchor such as [part 2 #1234 · 10-08 14:22]; pass the anchor as around to read what was said around it.', '- Reply in the user\'s language.', '- You keep a task list with track_tasks, and you use it to plan work and carry it, not only to show a status. When the user gives you work, or a group hands you work, record it there. Do not record work nobody asked for.', - '- In a group, the task lead records one overall task for the whole job (overall: true) and a subtask for each Bot that has a part (parent is that overall task\'s id or title, assignee is that Bot\'s name). The task lead is the Bot picked for that room, or the group admin when none is picked. Another Bot, including another Main Bot, does not divide the board. A member updates the card assigned to it, or links its own subtask with parent set to the overall task. One group has one open overall task; recording it again updates that same task.', - '- When a card is assigned to you, start the work. Do not wait for the user to confirm before you begin.', - '- Each task has a short title, a column (not-started, in-progress, done), how far along from 0 to 100, a priority (low, normal, high, urgent) and a short note. Update that same task as it moves, so the column and progress stay current.', + '- In a group, the task lead records one overall task for the whole job (overall: true) and a subtask for each Bot that has a part (parent is that overall task\'s id or title, assignee is that Bot\'s name). That record is the assignment. A chat message, including @Name, does not assign it. The task lead is the Bot picked for that room, or the group admin when none is picked. Another Bot, including another Main Bot, does not divide the board. A member updates the card assigned to it, or links its own subtask with parent set to the overall task. One group has one open overall task; recording it again updates that same task.', + '- When a card is assigned to you, claim it on the board: set its column to in-progress with track_tasks, then start the work. Do not wait for the user to confirm before you begin, and do not announce the claim in chat.', + '- Each task has a short title, a column (not-started, in-progress, done), how far along from 0 to 100, a priority (low, normal, high, urgent) and a short note. Update that same task as it moves, so the column and progress stay current. That update is the progress. Do not post a status essay in the group.', '- When you are given work, or you already have unfinished tasks from work you were given, continue the task track_tasks names next instead of inventing new work. Call track_tasks with no tasks to read the list. It prefers a higher priority (urgent, then high, then normal, then low), and an in-progress task before one that has not started when the priority is the same.', - '- A "[Continue task]" message is unfinished work you were already given. Do that task now and update it with track_tasks. Do not invent another job, send a status, or start a group round. In a group, post only when that task needs the group to see a result.', - '- When you cannot continue without an answer, pass ask with the one question on that task. Then ask it: in a group, in your reply there (never ask_user in your own chat); in your own chat, ask_user. When the answer arrives, pass answer with what they said and update the task. Do not leave the task waiting without that question.', + '- A "[Continue task]" message is unfinished work you were already given, including a group card. Do that task now and update its column, progress, and note with track_tasks. Do not invent another job, send a status, or start a group round. Post in the group only when a person has to decide.', + '- When you cannot continue without an answer, pass ask with the one question on that task. On a group card, worked in your own chat, call ask_user for the user, and post one short question in the group only when a person there has to decide. On your own chat\'s other tasks, ask_user. When the answer arrives, pass answer with what they said and update the task. Do not leave the task waiting without that question.', ].join('\n') const MAIN_RULES = [ diff --git a/core/src/host/room-context.js b/core/src/host/room-context.js new file mode 100644 index 000000000..74b3b2f85 --- /dev/null +++ b/core/src/host/room-context.js @@ -0,0 +1,167 @@ +// What a group turn shows the model of the room (#1203). The log on disk stays +// whole. The turn gets a newer-first slice under a character budget, and a long +// status essay is collapsed so it cannot fill that slice. Progress for the open +// job is a compact board snapshot in the same turn, not those essays. +import { compareForWork, progressOf } from '../shared/task-board.js' + +// Characters, not a tokenizer. The essays this was measured on are mostly CJK, +// about one token per character, so a character ceiling is the conservative one: +// English fits fewer tokens than this, never more. +export const ROOM_CONTEXT_BUDGET = 4000 + +// A bot line shorter than this is a handoff or a question, not an essay. +const STATUS_MIN = 400 + +const TERMINAL = new Set(['accepted', 'cancelled', 'failed']) + +// Two of these in a long bot line is CI / merge / review narration. A work +// assignment that only cites an issue number stays whole. +const STATUS_HINTS = [ + /merge[_ -]?group/i, + /tests-ok/i, + /\benqueue/i, + /\bmergeable\b/i, + /\bCI\b/, + /\bPASS\b/, + /Verdict/i, + /全绿/, + /入队/, + /合入/, + /队列/, + /\b[0-9a-f]{7,40}\b/, +] + +const oneLine = (value, max) => { + let flat = '' + for (const char of String(value ?? '')) { + const code = char.codePointAt(0) + flat += code < 0x20 || code === 0x7f || char === '<' || char === '>' ? ' ' : char + } + flat = flat.replace(/\s+/g, ' ').trim() + if (flat.length <= max) return flat + return `${flat.slice(0, Math.max(0, max - 1))}…` +} + +export function statusShaped(text) { + const body = String(text ?? '') + if (body.length < STATUS_MIN) return false + let hits = 0 + for (const hint of STATUS_HINTS) if (hint.test(body)) hits += 1 + return hits >= 2 +} + +const DRIVING_STATUS = new Set(['open', 'claimed', 'working', 'blocked', 'result-ready']) + +// A bare acknowledgement. Anything that asks for work stays a turn. +const CUE_ACK = /^(done|ok|okay|noted|shipped|passed|继续|好了|完成|已完成|搞定|收到)[.!。!]?$/i + +const foldCue = (value) => String(value ?? '') + .replace(/@[^\s@,,。]{1,40}/g, ' ') + .replace(/\p{P}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase() + +// A Bot @ is already on the board when this member has an open card in the room +// and the whole line, with @names and punctuation folded out, is already in that +// card (#1205). A title that only begins a new instruction is not covered. +// A question is never covered: that line is the one short ask a person may need. +export function boardCoversCue(tasks, { roomId, botId, text } = {}) { + if (!roomId || !botId) return false + const cards = Object.values(tasks ?? {}).filter(task => + task?.origin?.roomId === roomId + && task.ownerBotId === botId + && task.kind !== 'group' + && task.waitingFor !== 'input' + && DRIVING_STATUS.has(task.status)) + if (cards.length === 0) return false + const line = String(text ?? '') + if (/[??]/.test(line)) return false + if (statusShaped(line)) return true + const body = foldCue(line) + if (body.length === 0 || CUE_ACK.test(body)) return true + const hay = foldCue(cards.map(task => `${task.title ?? ''} ${task.note ?? ''}`).join('\n')) + return body.length >= 8 && hay.includes(body) +} + +// The first hundred characters, plus up to four @names, so a call is still visible +// after the essay is gone. The character count is the length before the cut. +export function compactStatus(text) { + const flat = oneLine(text, 100000) + const mentions = [...new Set([...flat.matchAll(/@[^\s@,,。]{1,40}/g)].map(match => match[0]))].slice(0, 4) + const head = flat.slice(0, 100).trim() + const more = flat.length > 100 ? '…' : '' + const called = mentions.length > 0 ? ` ${mentions.join(' ')}` : '' + return `[status on the board, ${flat.length} chars] ${head}${more}${called}` +} + +// Bot speech only. A person's line, and a short line, stay as stored. The log +// object is not modified: a collapsed line is a copy. +export function prepareRoomLine(line) { + if (line == null || typeof line !== 'object') return line + if (line.kind || line.botId === undefined || typeof line.text !== 'string') return line + if (!statusShaped(line.text)) return line + return { ...line, text: compactStatus(line.text) } +} + +// Newest line first until `budget` characters of rendered text. A line that does +// not fit is skipped, so a short older line can still land when a long one +// between them cannot. One line longer than the budget is clipped to it. +export function fitRoomLines(lines, { budget = ROOM_CONTEXT_BUDGET, render, prepare = line => line } = {}) { + const source = Array.isArray(lines) ? lines : [] + const kept = [] + let used = 0 + for (let index = source.length - 1; index >= 0; index -= 1) { + const line = prepare(source[index]) + let text = String(render(line)) + const gap = kept.length > 0 ? 1 : 0 + if (kept.length > 0 && used + gap + text.length > budget) continue + if (used + gap + text.length > budget) { + const room = budget - used - gap + if (room < 48) break + text = `${text.slice(0, room - 1)}…` + kept.push({ line, text }) + break + } + used += gap + text.length + kept.push({ line, text }) + } + kept.reverse() + return { + entries: kept, + text: kept.map(item => item.text).join('\n'), + omitted: source.length - kept.length, + } +} + +function cardLine(task, nameOf) { + const who = oneLine(typeof nameOf === 'function' ? nameOf(task) : '', 40) || 'unassigned' + const title = oneLine(task.title || '(untitled)', 80) + const status = oneLine(task.status || 'open', 24) + const pct = typeof task.progress === 'number' && Number.isFinite(task.progress) ? ` ${progressOf(task)}%` : '' + const wait = task.waitingFor === 'input' ? ' waiting' : '' + const note = task.note ? ` — ${oneLine(task.note, 80)}` : '' + return `${who} [${status}${pct}${wait}] ${title}${note}` +} + +// Open cards for one room: the newest overall task, then its subtasks and any +// room card that is not under a different overall task. Done cards stay off. +export function formatRoomBoard(tasks, { roomId, nameOf, limit = 8 } = {}) { + if (!roomId) return '' + const open = Object.values(tasks ?? {}).filter(task => task && task.origin?.roomId === roomId && !TERMINAL.has(task.status)) + if (open.length === 0) return '' + const overall = open.filter(task => task.kind === 'group').sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0] + const cards = [] + if (overall) cards.push({ task: overall, indent: '' }) + for (const task of open.filter(item => item !== overall && item.kind !== 'group').sort(compareForWork)) { + if (cards.length >= limit) break + if (overall && task.parentId && task.parentId !== overall.id) continue + cards.push({ task, indent: overall && task.parentId === overall.id ? ' ' : '' }) + } + if (cards.length === 0) return '' + const lines = ['Task board (assign, claim, and progress live here; update it with track_tasks, do not restate it in chat):'] + for (const { task, indent } of cards) lines.push(`${indent}- ${cardLine(task, nameOf)}`) + const hidden = open.length - cards.length + if (hidden > 0) lines.push(`- ${hidden} more open card(s) not shown`) + return lines.join('\n') +} diff --git a/core/src/host/task-drive.js b/core/src/host/task-drive.js index 905aa592e..bc244e67b 100644 --- a/core/src/host/task-drive.js +++ b/core/src/host/task-drive.js @@ -1,10 +1,11 @@ // Unfinished conversation tasks wake the Bot that owns them (#1109). The board // stays the one in tasks.js: this module only decides when an idle Session should -// continue the task track_tasks would name next. An own-chat task is a follow-up -// in that chat. A group subtask is a turn in that Bot's group Session (#1182): -// the reply is posted into the room when the turn ends, and the driver does not -// open a round of its own. +// continue the task track_tasks would name next. Every wake, including a group +// subtask, is a follow-up in that Bot's own chat (#1205). The driver does not +// open a group Session and does not post the reply into the room. Assigning, +// progress, and the next assignee are board writes; chat is not the scheduler. import { compareForWork } from '../shared/task-board.js' +import { formatRoomBoard } from './room-context.js' // A no-op track_tasks call still bumps updatedAt. The hash leaves that stamp out, // so a wake that did not move the board waits before the next one. @@ -35,17 +36,19 @@ const cueFor = (task) => { const post = task.writeBack?.state === 'pending' ? ` Then post the decision comment on ${task.writeBack.target} with your GitHub connector and record it with track_tasks postResult {task: '${task.id}', ok: true}, or ok: false with the error.` : '' - const lead = `Continue ${task.id} [${priority}] ${title}. Do this task now. Update it with track_tasks as it moves. Stop when it is done or waiting on an answer. Do not invent other work` + const head = `Continue ${task.id} [${priority}] ${title}. Do this task now.` + const stop = 'Stop when it is done or waiting on an answer. Do not invent other work' if (task.origin?.roomId) { - return `${lead} or record a new plan. This card is already yours: start the work and do not wait for a confirmation. If you need an answer, ask that one question in your reply here, never in your own chat.${post}` + return `${head} Taking it and moving it are board writes: set this card's column and progress with track_tasks, and do not post a status in the group. ${stop} or record a new plan. This card is already yours: set its column to in-progress and start the work, and do not wait for a confirmation. Update the note on the card as it moves. The group stays quiet. If a person has to decide, post one short question in the group; do not @ a Bot to hand the work on. Completing or updating this card wakes the next assignee.${post}` } - return `${lead} or start a group round.${post}` + return `${head} Set this card's column and progress with track_tasks as it moves. ${stop} or start a group round.${post}` } const messageText = (message) => (Array.isArray(message?.content) ? message.content : []).map(block => block?.text ?? '').join('\n') -// An own-chat wake is role `task`. A group wake is a room turn (`taskDrive`), so -// the same inbox check has to see it or the next board write queues a second one. +// A wake is role `task` in the Bot's own chat, including a group card (#1205). +// A room turn left from an older driver (`taskDrive`) still counts, so a board +// write does not queue a second wake beside one that has not started. const taskMessages = (agent) => [...(agent?.inbox?.nextTurn ?? []), ...(agent?.inbox?.nextStep ?? [])] .filter(message => message?.source?.role === 'task' || message?.source?.taskDrive === true) @@ -86,34 +89,22 @@ export function install(rt) { return false } + // The wake always lands in the owner's own chat. A group card still has to + // name a member of that room; a face-to-face room has no board. const ownsSession = (task, sessionId) => { - const roomId = task.origin?.roomId - if (!roomId) return rt.chatOf(task.ownerBotId) === sessionId - return rt.roomOf(roomId)?.sessions?.[task.ownerBotId] === sessionId + const chat = rt.chatOf(task.ownerBotId) + return chat === sessionId && !rt.roomOf(sessionId) } - // The group Session when this Bot already has one there, or a new one when the - // subtask is the first thing it does in that group. A live round waits: the - // driver must not add a member turn beside the round. - const sessionFor = async (task) => { + const sessionFor = (task) => { const roomId = task.origin?.roomId - if (!roomId) { - const id = rt.chatOf(task.ownerBotId) - return id && !rt.roomOf(id) ? id : undefined - } - const room = rt.roomOf(roomId) - if (!room || room.origin === 'face-to-face') return undefined - if (!(room.members ?? []).includes(task.ownerBotId)) return undefined - if (roundLive(room.id)) return undefined - const existing = room.sessions?.[task.ownerBotId] - if (existing) return existing - if (typeof rt.memberSession !== 'function') return undefined - try { - return await rt.memberSession(room, task.ownerBotId) - } catch (error) { - rt.warn('ds-bot: task drive could not open a group session for %s: %s', task.ownerBotId, error) - return undefined + if (roomId) { + const room = rt.roomOf(roomId) + if (!room || room.origin === 'face-to-face') return undefined + if (!(room.members ?? []).includes(task.ownerBotId)) return undefined } + const id = rt.chatOf(task.ownerBotId) + return id && !rt.roomOf(id) ? id : undefined } // `live` is cleared before the turn-end hook runs. The Agent's status can still @@ -194,10 +185,10 @@ export function install(rt) { if (!list.includes(task)) continue byBot.set(task.ownerBotId, list) } - // One pass wakes every owner. A group assignment starts each member's own - // subtask in that same drive, in that member's Session. One Bot still gets - // only the task compareForWork names next, and a Session that already has - // a turn is left until that turn ends. + // One pass wakes every owner, in that owner's own chat. A group assignment + // starts each assignee there in the same drive and does not open a round. + // One Bot still gets only the task compareForWork names next, and a Session + // that already has a turn is left until that turn ends. for (const [botId, owned] of byBot) { const list = owned.slice().sort(compareForWork) const next = list[0] @@ -230,15 +221,17 @@ export function install(rt) { inflight.set(sessionId, { fingerprint: fp, started: false }) const roomId = next.origin?.roomId try { - // A group card is a room turn: the reply posts into the group when the - // turn ends (turns.js, no waiter), so a question stays there (#1182). - // noteWake counts only a wake that left, including this room turn (#1172). + // Own chat, even for a group card (#1205). The reply is not a room turn, + // so it is not posted into the group when the turn ends. noteWake counts + // only a wake that left (#1172). The board snapshot is the progress. + const board = roomId + ? formatRoomBoard(rt.state.tasks, { roomId, nameOf: task => rt.botOf(task.ownerBotId)?.name }) + : '' await rt.deliver({ toId: botId, sessionId, - role: roomId ? 'room' : 'task', - text: cueFor(next), - ...(roomId ? { roomId, note: 'your task', taskDrive: true } : {}), + role: 'task', + text: board ? `${cueFor(next)}\n\n${board}` : cueFor(next), }) await noteWake(next) } catch (error) { diff --git a/core/src/host/tools.js b/core/src/host/tools.js index 316888e32..7dc833225 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -810,7 +810,7 @@ ${task.writeBack.body}`) for (const task of ordered) { if (task.waitingFor !== 'input' || !task.inputRequest?.question) continue cues.push(inGroup - ? `Waiting ${task.id}: ask the group in your reply: ${task.inputRequest.question}` + ? `Waiting ${task.id}: the card is waiting. Call ask_user for the user. Post one short question in the group only when a person there has to decide: ${task.inputRequest.question}` : `Waiting ${task.id}: call ask_user to ask the user: ${task.inputRequest.question}`) } if (withNext) { @@ -831,7 +831,7 @@ ${task.writeBack.body}`) ctx.tools.register(defineTool({ name: 'track_tasks', - description: 'Read or update tasks on the board. Omit tasks to read your list: the first lines name the unfinished task to continue (higher priority first) and any task waiting on an answer. Pass tasks to create or update your own. In a group, the task lead (the Bot picked for the room, or the admin when none is picked) records one overall task (overall: true) and a subtask per Bot (parent is the overall task\'s id or title, assignee is that Bot\'s name). Another Main Bot does not plan. A member updates the card assigned to it, or links its own subtask with parent. Do not record a second plan. Match an id, or the title of a task still open. Each task has a title, a column (not-started, in-progress, done), progress from 0 to 100, a priority (low, normal, high, urgent) and a note. Pass ask when you need an answer before the task can continue, and answer when the user or the group has answered. In a group, ask in your reply there, not in your own chat. In a group the task is tracked for that group.', + description: 'Read or update tasks on the board. Assigning work, claiming it, and recording progress are this tool, not a chat message. Omit tasks to read your list: the first lines name the unfinished task to continue (higher priority first) and any task waiting on an answer. Pass tasks to create or update your own. In a group, the task lead (the Bot picked for the room, or the admin when none is picked) records one overall task (overall: true) and a subtask per Bot (parent is the overall task\'s id or title, assignee is that Bot\'s name). Another Main Bot does not plan. A member claims the card assigned to it by setting its column to in-progress, updates that card\'s column and progress as it moves, or links its own subtask with parent. Do not record a second plan. Match an id, or the title of a task still open. Each task has a title, a column (not-started, in-progress, done), progress from 0 to 100, a priority (low, normal, high, urgent) and a note. Pass ask when you need an answer before the task can continue, and answer when the user or the group has answered. A group card is worked in your own chat. Post one short question in the group only when a person has to decide. In a group the task is tracked for that group.', parameters: { tasks: { type: 'array', @@ -850,7 +850,7 @@ ${task.writeBack.body}`) overall: { type: 'boolean', description: 'Record this as the group\'s one overall task. Only the room\'s task lead may set it (the group admin, when no lead is picked). A later call updates that same open task.' }, parent: { type: 'string', description: 'Id or title of the overall group task this subtask belongs to.' }, assignee: { type: 'string', description: 'Name of the Bot that owns this subtask. Only the room\'s task lead may name another member. The card is claimed, and that Bot starts the work without waiting for a confirmation.' }, - ask: { type: 'string', description: 'The question you need before this task can continue. The task waits. In your own chat, call ask_user; in a group, ask in your reply.' }, + ask: { type: 'string', description: 'The question you need before this task can continue. The task waits. Call ask_user in your own chat. For a group card, post one short question in the group only when a person has to decide.' }, answer: { type: 'string', description: 'What the user or the group answered. Pass it to resume a task that was waiting.' }, target: { type: 'string', description: 'Where a decision on this task is written back: a GitHub issue or pull request, as https://github.com/owner/repo/issues/12 or owner/repo#12. Optional.' }, postResult: { diff --git a/core/test/bot-tasks.test.js b/core/test/bot-tasks.test.js index 43b5cd62f..ff49a66c6 100644 --- a/core/test/bot-tasks.test.js +++ b/core/test/bot-tasks.test.js @@ -295,7 +295,7 @@ test('a group keeps one overall task and a subtask per Bot, and unfinished work assert.match(chief, /Next: \S+ \[in-progress high\] Going high/) const asked = await app.track('s-war', { tasks: [{ id: draft.id, ask: 'Which tone?' }] }) - assert.match(asked, /ask the group in your reply: Which tone\?/) + assert.match(asked, /Post one short question in the group only when a person there has to decide: Which tone\?/) const waiting = (await list(app.call, alice.sessionToken, { botId: 's' })).find(task => task.id === draft.id) assert.equal(waiting.status, 'blocked') assert.equal(waiting.waitingFor, 'input') diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index 9f88cfc06..5dccdd781 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -131,6 +131,8 @@ test('the board draws the three columns, nests the child, and moves only the own }) assert.match(textOf(group), /Theirs/) assert.match(textOf(group), /Other/) + assert.match(textOf(group), /Assign, claim, and progress live on this board/) + assert.doesNotMatch(textOf(tree), /Assign, claim, and progress live on this board/) assert.equal(all(group).some(node => node.type === 'button' && (textOf(node) === 'Start' || textOf(node) === 'Complete')), false) assert.equal(byClass(group, 'bt-board-card')[0].props.draggable, undefined) }) diff --git a/core/test/prompt.test.js b/core/test/prompt.test.js index 4bb10c2fa..4a0902d9c 100644 --- a/core/test/prompt.test.js +++ b/core/test/prompt.test.js @@ -67,7 +67,12 @@ test('a Bot stays idle until it is addressed or given a task (part B of #645)', assert.match(prompt('worker'), /overall: true/) assert.match(prompt('worker'), /the task lead records one overall task/) assert.match(prompt('worker'), /Do not wait for the user to confirm/) - assert.match(prompt('worker'), /never ask_user in your own chat/) + assert.doesNotMatch(prompt('worker'), /hand work to another with @Name/) + assert.match(prompt('worker'), /@Name only wakes a member; it does not assign work or record progress/) + assert.match(prompt('worker'), /set its column to in-progress with track_tasks/) + assert.match(prompt('worker'), /That update is the progress/) + assert.match(prompt('worker'), /post one short question in the group only when a person there has to decide/) + assert.doesNotMatch(prompt('worker'), /never ask_user in your own chat/) assert.match(prompt('worker'), /pass ask with the one question/) assert.equal(prompt('worker').split('overall: true').length - 1, 1) assert.doesNotMatch(prompt('worker'), /coordinator/, 'the coordinator wording belongs to the Main Bot') diff --git a/core/test/room-context.test.js b/core/test/room-context.test.js new file mode 100644 index 000000000..60c5cc199 --- /dev/null +++ b/core/test/room-context.test.js @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ROOM_CONTEXT_BUDGET, boardCoversCue, compactStatus, fitRoomLines, formatRoomBoard, prepareRoomLine, statusShaped } from '../src/host/room-context.js' + +const render = line => `${line.who}: ${line.text}` +const essay = (tail) => `CI on tip abcdef12 is green. tests-ok passed. enqueue when mergeable. merge_group is next. PASS at abcdef12. @Chief ${'detail '.repeat(80)}${tail}` + +test('a long CI essay is status-shaped and a work note is not', () => { + assert.equal(statusShaped(essay('end')), true) + assert.equal(statusShaped('CI enqueue'), false, 'a short line is a handoff') + const work = `Please change docs/autonomous-dev.md so the four spots say the mechanism is not enabled. ${'word '.repeat(80)}` + assert.equal(statusShaped(work), false) +}) + +test('collapsing a status line keeps the opening and the @name, not the essay', () => { + const text = essay('ESSAY_BODY_MARKER') + assert.ok(text.indexOf('ESSAY_BODY_MARKER') > 200) + const compact = compactStatus(text) + assert.match(compact, /^\[status on the board, \d+ chars\] /) + assert.match(compact, /@Chief/) + assert.doesNotMatch(compact, /ESSAY_BODY_MARKER/) + assert.ok(compact.length < 220) +}) + +test('prepare collapses bot status and leaves the stored line and a person line', () => { + const stored = { seq: 1, who: 'Dev', botId: 'dev', text: essay('TAIL') } + const next = prepareRoomLine(stored) + assert.equal(stored.text.includes('TAIL'), true, 'the log line is not rewritten') + assert.notEqual(next, stored) + assert.match(next.text, /\[status on the board/) + assert.doesNotMatch(next.text, /TAIL/) + const person = { seq: 2, who: 'User', text: essay('PERSON') } + assert.equal(prepareRoomLine(person), person) + const short = { seq: 3, who: 'Dev', botId: 'dev', text: 'short handoff keep me' } + assert.equal(prepareRoomLine(short), short) +}) + +test('the transcript stays inside the budget and prefers a newer short line over an older essay', () => { + const lines = [] + for (let seq = 1; seq <= 8; seq += 1) lines.push({ seq, who: 'Dev', botId: 'dev', text: essay(`marker-${seq}`) }) + lines.push({ seq: 9, who: 'Dev', botId: 'dev', text: 'short handoff keep me' }) + const fitted = fitRoomLines(lines, { budget: ROOM_CONTEXT_BUDGET, render, prepare: prepareRoomLine }) + assert.ok(fitted.text.length <= ROOM_CONTEXT_BUDGET) + assert.match(fitted.text, /short handoff keep me/) + assert.match(fitted.text, /\[status on the board/) + assert.doesNotMatch(fitted.text, /marker-/) + assert.equal(fitted.entries.at(-1).line.seq, 9) +}) + +test('a non-status flood is cut newest-first instead of being collapsed', () => { + const lines = [] + for (let seq = 1; seq <= 10; seq += 1) lines.push({ seq, who: 'Dev', botId: 'dev', text: `work item ${seq} ${'word '.repeat(160)}` }) + const fitted = fitRoomLines(lines, { budget: 1000, render, prepare: prepareRoomLine }) + assert.ok(fitted.text.length <= 1000) + assert.match(fitted.text, /work item 10 /) + assert.doesNotMatch(fitted.text, /work item 1 /) + assert.ok(fitted.omitted > 0) + assert.equal(fitted.entries.at(-1).text.includes('word '), true, 'a work line is not rewritten as status') +}) + +test('one line longer than the budget is clipped to it', () => { + const lines = [{ seq: 1, who: 'User', text: 'x'.repeat(ROOM_CONTEXT_BUDGET + 500) }] + const fitted = fitRoomLines(lines, { budget: ROOM_CONTEXT_BUDGET, render, prepare: prepareRoomLine }) + assert.ok(fitted.text.length <= ROOM_CONTEXT_BUDGET) + assert.equal(fitted.omitted, 0) + assert.ok(fitted.text.endsWith('…')) +}) + +test('the board snapshot is the open overall task and its subtasks', () => { + const tasks = { + overall: { id: 'overall', kind: 'group', status: 'open', title: 'Ship note', progress: 40, note: 'line one\nline two', ownerBotId: 'chief', origin: { roomId: 'room' }, updatedAt: 2 }, + child: { id: 'child', status: 'claimed', title: 'Writer implements', parentId: 'overall', ownerBotId: 'writer', origin: { roomId: 'room' }, updatedAt: 3, waitingFor: 'input' }, + done: { id: 'done', status: 'accepted', title: 'FINISHED_MARKER', ownerBotId: 'writer', origin: { roomId: 'room' }, updatedAt: 4 }, + other: { id: 'other', status: 'open', title: 'OTHER_ROOM', ownerBotId: 'writer', origin: { roomId: 'elsewhere' }, updatedAt: 5 }, + stray: { id: 'stray', kind: 'group', status: 'open', title: 'Older plan', ownerBotId: 'chief', origin: { roomId: 'room' }, updatedAt: 1 }, + } + const names = { chief: 'Chief', writer: 'Writer' } + const board = formatRoomBoard(tasks, { roomId: 'room', nameOf: task => names[task.ownerBotId] }) + assert.match(board, /Task board \(assign, claim, and progress live here/) + assert.match(board, /Chief \[open 40%\] Ship the note — line one line two/) + assert.match(board, /Writer \[claimed waiting\] Writer implements/) + assert.doesNotMatch(board, /FINISHED_MARKER|OTHER_ROOM|Older plan|<|>/) + assert.equal(formatRoomBoard({}, { roomId: 'room' }), '') + assert.equal(formatRoomBoard(tasks, { roomId: 'missing' }), '') +}) + +test('an open card covers a status @ and not a question or a new ask', () => { + const tasks = { + child: { id: 'child', status: 'working', title: 'Writer implements', note: 'draft is in', progress: 40, ownerBotId: 'writer', origin: { roomId: 'room' } }, + waiting: { id: 'waiting', status: 'blocked', title: 'Need a name', waitingFor: 'input', ownerBotId: 'writer', origin: { roomId: 'room' } }, + } + const cue = { roomId: 'room', botId: 'writer' } + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer Writer implements' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer Writer implements — draft is in' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer draft is in' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer Writer implements the rollback instead' }), false) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer Writer implements, then delete the notes file' }), false) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer done' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: essay('@Writer') }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer which file?' }), false) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer the draft is wrong, redo paragraph two' }), false) + assert.equal(boardCoversCue(tasks, { roomId: 'room', botId: 'other', text: '@Other Writer implements' }), false) + assert.equal(boardCoversCue({ only: { ...tasks.waiting } }, { ...cue, text: '@Writer done' }), false) +}) diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index bfafb392d..75de093a3 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -1572,10 +1572,12 @@ test('a member reads the group\'s rules and notice from its standing section, an assert.match(standing, /^# This group chat/) assert.ok(standing.includes(`Group notice (shared instructions for every member):\n${notice}`), 'the notice is in the section') assert.match(standing, /Members: the user, /) - assert.match(standing, /Writing "@Name" calls that member next/) + assert.match(standing, /Writing "@Name" wakes that member/) + assert.match(standing, /It does not assign work or record progress/) + assert.doesNotMatch(standing, /one short handoff line/) assert.match(standing, /Reply \[PASS\] to stay silent/) assert.ok(!text.includes('Rule one'), 'no turn repeats the notice') - assert.doesNotMatch(text, /Members: the user|Writing "@Name" calls|Group notice|You are this group's admin/) + assert.doesNotMatch(text, /Members: the user|Writing "@Name"|Group notice|You are this group's admin/) assert.match(text, /Speaking order for this round/) assert.match(text, /Reply now with your message for the group, no name prefix, or \[PASS\] to stay silent\.$/) } @@ -1649,6 +1651,8 @@ test('a face room\'s roster and routing rule stand in its section; a routed turn assert.match(standing, /Default to \[PASS\]: exactly one Bot answers a line like this, and the first Bot that answers ends it/) assert.match(standing, /A line addressed to everyone .* is the exception/) assert.match(standing, /\[PASS\] posts nothing and nobody sees it: never explain a pass\./) + assert.match(standing, /Writing "@Name" calls that member next/) + assert.doesNotMatch(standing, /does not assign work or record progress/) assert.match(text, /This line names no Bot\. Default to \[PASS\]: exactly one Bot answers it; answer only if it is for you, by the rule for such lines in your instructions for this group\./) assert.doesNotMatch(text, /face-to-face room with people|What members do|never explain a pass|the first Bot that answers ends it|Writing "@Name" calls/) } @@ -1888,3 +1892,146 @@ test('#932: a member called mid-round is reported as called, never as stayed sil assert.match(text, /Called, but no answer: Editor\./, 'a member called mid-round that said nothing is reported as called') assert.doesNotMatch(text, /Stayed silent: [^.]*Editor/, 'never in the sentence for a member nobody called') }) + +// #1203: message-count caps still let a handful of CI essays fill the first look. +// The turn the model reads stays inside a character budget, those essays collapse, +// and the open board is the progress. The stored log keeps the essay. +test('#1203 a group turn budgets room context and reads progress from the board', async (t) => { + const { call, emit, userMessage, relay, answer, stop, sent, ctx, rt } = start() + t.after(stop) + const chief = (await call('state', {})).value.mainBotId + const writer = (await call('create-bot', { name: 'Writer' })).value + const room = (await call('create-room', { name: 'R1203', members: [chief, writer.id] })).value + await call('update-room', { id: room.id, mode: 'everyone', notice: 'Keep this notice verbatim.' }) + const marker = 'ESSAY_BODY_MARKER_1203' + const essay = `CI on tip abcdef12 is green. tests-ok passed. enqueue when mergeable. merge_group is next. PASS at abcdef12. @Chief ${'detail '.repeat(180)}${marker}` + assert.ok(essay.indexOf(marker) > 400) + await rt.load() + const live = rt.roomOf(room.id) + live.log = [] + live.seq = 0 + live.seen = {} + for (let seq = 1; seq <= 10; seq += 1) { + live.log.push({ seq, who: 'Writer', botId: writer.id, authorId: writer.id, authorKind: 'bot', text: `${essay} ${seq}` }) + } + live.seq = 11 + live.log.push({ seq: 11, who: 'Writer', botId: writer.id, authorId: writer.id, authorKind: 'bot', text: 'short handoff keep me' }) + rt.state.tasks ??= {} + Object.assign(rt.state.tasks, { + 'task-overall': { + id: 'task-overall', status: 'open', kind: 'group', title: 'Ship the note', progress: 40, + ownerBotId: chief, source: 'conversation', origin: { roomId: room.id }, updatedAt: 2, createdAt: 1, + }, + 'task-sub': { + id: 'task-sub', status: 'claimed', title: 'Writer implements', parentId: 'task-overall', + note: 'keep the card \nnext', ownerBotId: writer.id, source: 'conversation', + origin: { roomId: room.id }, updatedAt: 3, createdAt: 2, + }, + 'task-done': { + id: 'task-done', status: 'accepted', title: 'FINISHED_MARKER_1203', ownerBotId: writer.id, + source: 'conversation', origin: { roomId: room.id }, updatedAt: 4, createdAt: 1, + }, + }) + sent.length = 0 + await emit(room.id, userMessage('go on')) + const running = relay(room.id, 'r1203') + const first = await turnFor(sent) + const prompt = promptOf(first) + const earlier = prompt.split('Earlier in the group, before this round (context only):\n')[1]?.split('\n\n')[0] ?? '' + assert.ok(earlier.length > 0 && earlier.length <= 4000, `earlier room context is ${earlier.length} chars`) + assert.match(prompt, /short handoff keep me/) + assert.doesNotMatch(prompt, /ESSAY_BODY_MARKER_1203/) + assert.match(prompt, /\[status on the board, \d+ chars\]/) + assert.match(prompt, /Task board \(assign, claim, and progress live here/) + assert.match(prompt, /\[open 40%\] Ship the note/) + assert.match(prompt, /Writer \[claimed\] Writer implements — keep the card secret next/) + assert.doesNotMatch(prompt, /FINISHED_MARKER_1203/) + const standing = standingOf(ctx, first.sessionId) + assert.match(standing, /Keep this notice verbatim\./) + assert.match(standing, /Do not narrate CI/) + assert.match(standing, /assign it on the board with track_tasks/) + assert.match(standing, /A chat line does not assign/) + assert.match(standing, /does not assign or record progress/) + assert.doesNotMatch(standing, /one short handoff line/) + assert.doesNotMatch(standing, /hand work to another/) + assert.doesNotMatch(prompt, /Keep this notice verbatim/) + assert.match(prompt, /Reply now with your message for the group, no name prefix, or \[PASS\] to stay silent\.$/) + assert.ok(live.log.some(line => String(line.text).includes(marker)), 'the stored log still has the essay') + await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) + const second = await turnFor(sent, first) + const memberStanding = standingOf(ctx, second.sessionId) + await answer(second.sessionId, second.message.source.exchangeId, '[PASS]', 2) + await running + assert.match(memberStanding, /setting its column to in-progress with track_tasks/) + assert.match(memberStanding, /@Name only wakes someone; it does not assign/) + assert.doesNotMatch(memberStanding, /one short handoff line/) +}) + +test('a Bot @ whose open card already shows the line passes without a turn (#1205)', async (t) => { + const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() + t.after(stop) + const chief = (await call('state', {})).value.mainBotId + const writer = (await call('create-bot', { name: 'Writer' })).value + const room = (await call('create-room', { name: 'R1205', members: [chief, writer.id] })).value + await call('update-room', { id: room.id, mode: 'everyone' }) + await rt.load() + const live = rt.roomOf(room.id) + rt.state.tasks ??= {} + Object.assign(rt.state.tasks, { + 'task-overall': { + id: 'task-overall', status: 'open', kind: 'group', title: 'Ship the note', + ownerBotId: chief, source: 'conversation', origin: { roomId: room.id }, updatedAt: 1, createdAt: 1, + }, + 'task-sub': { + id: 'task-sub', status: 'working', title: 'Writer implements', progress: 40, note: 'draft is in', + ownerBotId: writer.id, source: 'conversation', origin: { roomId: room.id }, updatedAt: 2, createdAt: 1, + }, + }) + const controller = new AbortController() + t.after(() => controller.abort()) + sent.length = 0 + await emit(room.id, botLine(chief, 'Chief', '@Writer Writer implements — draft is in')) + const covered = JSON.parse(await relay(room.id, 'r1205-cover', controller.signal)) + assert.equal(sent.length, 0, 'the card already shows the line, so Writer is not given a turn') + assert.ok(covered.passed.includes('Writer')) + + sent.length = 0 + await emit(room.id, botLine(chief, 'Chief', '@Writer Writer implements the rollback instead')) + const redirecting = relay(room.id, 'r1205-redirect', controller.signal) + const redirected = await turnFor(sent) + await answer(redirected.sessionId, redirected.message.source.exchangeId, 'Leaving the draft.', 4) + await redirecting + assert.equal(redirected.sessionId, live.sessions[writer.id], 'a title plus a new instruction still opens a turn') + + sent.length = 0 + await emit(room.id, botLine(chief, 'Chief', '@Writer Writer implements, then delete the notes file')) + const deleting = relay(room.id, 'r1205-delete', controller.signal) + const deleted = await turnFor(sent) + await answer(deleted.sessionId, deleted.message.source.exchangeId, 'Deleting the notes file.', 5) + await deleting + assert.equal(deleted.sessionId, live.sessions[writer.id], 'a title plus another new instruction still opens a turn') + + sent.length = 0 + await emit(room.id, botLine(chief, 'Chief', '@Writer the draft is wrong, redo paragraph two')) + const asking = relay(room.id, 'r1205-ask', controller.signal) + const asked = await turnFor(sent) + await answer(asked.sessionId, asked.message.source.exchangeId, 'Rewriting paragraph two.', 1) + await asking + assert.equal(asked.sessionId, live.sessions[writer.id], 'a new ask still opens a turn') + + sent.length = 0 + await emit(room.id, botLine(chief, 'Chief', '@Writer which file should the note use?')) + const questioning = relay(room.id, 'r1205-question', controller.signal) + const question = await turnFor(sent) + await answer(question.sessionId, question.message.source.exchangeId, 'The notes file.', 2) + await questioning + assert.equal(question.sessionId, live.sessions[writer.id]) + + sent.length = 0 + await emit(room.id, userMessage('@Writer Writer implements')) + const personRound = relay(room.id, 'r1205-person', controller.signal) + const person = await turnFor(sent) + await answer(person.sessionId, person.message.source.exchangeId, 'On it.', 3) + await personRound + assert.equal(person.sessionId, live.sessions[writer.id], 'a person @ still gets an answer') +}) diff --git a/core/test/task-drive.test.js b/core/test/task-drive.test.js index d03c96a33..8b4bf966e 100644 --- a/core/test/task-drive.test.js +++ b/core/test/task-drive.test.js @@ -1,8 +1,8 @@ // Unfinished conversation tasks wake the owning Bot (#1109). A wake is a follow-up -// in that Bot's own chat. A group subtask is a room turn in that Bot's group -// Session (#1182); the reply is posted into the room when the turn ends. A task -// waiting on an answer, a turn already open, and a wake that did not move the -// board do not start another one. An overall group card is the plan, not a turn. +// in that Bot's own chat, including a group subtask (#1205): the driver does not +// open a group Session and does not post the reply into the room. A task waiting +// on an answer, a turn already open, and a wake that did not move the board do +// not start another one. An overall group card is the plan, not a turn. import assert from 'node:assert/strict' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -235,18 +235,22 @@ test('finishing the named task before its wake starts continues the next one', a assert.match(agent.inbox.nextTurn[0].content.map(block => block.text).join('\n'), new RegExp(low)) }) -test('a group subtask wakes that group session and not the room or another Bot', async (t) => { +test('a group subtask wakes the assignee own chat and not the room', async (t) => { const app = await start(t) const body = await app.track('a', { tasks: [ { title: 'Ship the note', overall: true, group: 'War', priority: 'urgent' }, { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'high' }, ] }) - app.idOf(body, 'Write the draft') - assert.match(app.text('s-war'), /\[Group chat "War" · your task\]/) - assert.equal(app.calls('s-war')[0].source.role, 'room') - assert.equal(app.calls('s-war')[0].source.taskDrive, true) - assert.match(app.text('s-war'), /Write the draft/) - assert.equal(app.calls('s').length, 0, 'the subtask runs in the group session') + const id = app.idOf(body, 'Write the draft') + assert.equal(app.calls('s').length, 1) + assert.equal(app.calls('s')[0].source.role, 'task') + assert.equal(app.calls('s')[0].source.taskDrive, undefined) + assert.equal(app.calls('s')[0].source.roomId, undefined) + assert.match(app.text('s'), /\[Continue task\]/) + assert.match(app.text('s'), new RegExp(`Continue ${id} \\[high\\] Write the draft`)) + assert.match(app.text('s'), /do not post a status in the group/) + assert.match(app.text('s'), /Task board \(assign, claim, and progress live here/) + assert.equal(app.calls('s-war').length, 0, 'the subtask does not open a group turn') assert.equal(app.calls('a').length, 0, 'the overall card does not wake its owner') assert.equal(app.calls('war').length, 0, 'the driver does not post into the room') assert.equal(app.calls('b').length, 0) @@ -262,19 +266,17 @@ test('an overall task on its own does not wake anyone', async (t) => { assert.equal(app.calls('war').length, 0) }) -test('a subtask opens the member session when the Bot has not spoken in the group', async (t) => { +test('a subtask wakes the own chat and does not open a group session', async (t) => { const app = await start(t) await app.track('a', { tasks: [ { title: 'Ship the note', overall: true, group: 'War' }, { title: 'Bob writes', parent: 'Ship the note', assignee: 'BobBot', group: 'War', priority: 'urgent' }, ] }) - const sessionId = app.rt.state.rooms.war.sessions.b - assert.equal(typeof sessionId, 'string') - assert.notEqual(sessionId, 'b') - assert.match(app.text(sessionId), /\[Group chat "War" · your task\]/) - assert.equal(app.calls(sessionId)[0].source.role, 'room') - assert.match(app.text(sessionId), /Bob writes/) - assert.equal(app.calls('b').length, 0) + assert.equal(app.rt.state.rooms.war.sessions.b, undefined) + assert.equal(app.calls('b').length, 1) + assert.equal(app.calls('b')[0].source.role, 'task') + assert.match(app.text('b'), /\[Continue task\]/) + assert.match(app.text('b'), /Bob writes/) assert.equal(app.calls('war').length, 0) }) @@ -289,39 +291,37 @@ test('group members advance their own subtasks in the same drive', async (t) => const write = app.idOf(body, 'Write the draft') const check = app.idOf(body, 'Check the facts') const bob = app.idOf(body, 'Bob writes') - const bobSession = app.rt.state.rooms.war.sessions.b - assert.equal(typeof bobSession, 'string') - assert.equal(app.calls('s-war').length, 1) - assert.match(app.text('s-war'), new RegExp(`Continue ${write} \\[high\\] Write the draft`)) - assert.doesNotMatch(app.text('s-war'), new RegExp(check)) - assert.equal(app.calls(bobSession).length, 1) - assert.match(app.text(bobSession), new RegExp(`Continue ${bob} \\[urgent\\] Bob writes`)) - assert.equal(app.calls('s').length, 0) - assert.equal(app.calls('b').length, 0) + assert.equal(app.calls('s').length, 1) + assert.match(app.text('s'), new RegExp(`Continue ${write} \\[high\\] Write the draft`)) + assert.doesNotMatch(app.text('s'), new RegExp(check)) + assert.equal(app.calls('b').length, 1) + assert.match(app.text('b'), new RegExp(`Continue ${bob} \\[urgent\\] Bob writes`)) + assert.equal(app.calls('s-war').length, 0) assert.equal(app.calls('a').length, 0) assert.equal(app.calls('war').length, 0) await app.ctx.taskDrive.consider() - assert.equal(app.calls('s-war').length, 1, 'a queued wake is not duplicated') - assert.equal(app.calls(bobSession).length, 1) + assert.equal(app.calls('s').length, 1, 'a queued wake is not duplicated') + assert.equal(app.calls('b').length, 1) }) test('one member already in a turn does not hold the other member back', async (t) => { const app = await start(t) - app.emit('s-war', 'turn/start', { turn: 1 }) + app.emit('s', 'turn/start', { turn: 1 }) const body = await app.track('a', { tasks: [ { title: 'Ship the note', overall: true, group: 'War' }, { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'high' }, { title: 'Bob writes', parent: 'Ship the note', assignee: 'BobBot', group: 'War', priority: 'urgent' }, ] }) const write = app.idOf(body, 'Write the draft') - const bobSession = app.rt.state.rooms.war.sessions.b - assert.equal(app.calls('s-war').length, 0, 'Writer is mid-turn, so that Session is not woken again') - assert.equal(app.calls(bobSession).length, 1) - app.emit('s-war', 'turn/end', { reason: { kind: 'completed' } }) + assert.equal(app.calls('s').length, 0, 'Writer is mid-turn, so that chat is not woken again') + assert.equal(app.calls('b').length, 1) + assert.equal(app.calls('war').length, 0) + app.emit('s', 'turn/end', { reason: { kind: 'completed' } }) await app.ctx.taskDrive.settled() - assert.equal(app.calls('s-war').length, 1) - assert.match(app.text('s-war'), new RegExp(`Continue ${write} \\[high\\] Write the draft`)) - assert.equal(app.calls(bobSession).length, 1) + assert.equal(app.calls('s').length, 1) + assert.match(app.text('s'), new RegExp(`Continue ${write} \\[high\\] Write the draft`)) + assert.equal(app.calls('b').length, 1) + assert.equal(app.calls('war').length, 0) }) test('a member waiting on an answer stays quiet while another member is woken', async (t) => { @@ -331,35 +331,34 @@ test('a member waiting on an answer stays quiet while another member is woken', { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'high' }, ] }) const write = app.idOf(planned, 'Write the draft') - assert.equal(app.calls('s-war').length, 1) - app.beginWake('s-war') - const asked = await app.track('s-war', { tasks: [{ id: write, ask: 'Which tone?' }] }) + assert.equal(app.calls('s').length, 1) + app.beginWake('s') + const asked = await app.track('s', { tasks: [{ id: write, ask: 'Which tone?' }] }) assert.match(asked, /Which tone\?/) - app.emit('s-war', 'turn/end', { reason: { kind: 'completed' } }) + app.emit('s', 'turn/end', { reason: { kind: 'completed' } }) await app.ctx.taskDrive.settled() const body = await app.track('a', { tasks: [ { title: 'Bob writes', parent: 'Ship the note', assignee: 'BobBot', group: 'War', priority: 'urgent' }, ] }) app.idOf(body, 'Bob writes') - const bobSession = app.rt.state.rooms.war.sessions.b - assert.equal(app.calls('s-war').length, 1, 'a task waiting on an answer does not wake again') - assert.equal(app.calls(bobSession).length, 1) + assert.equal(app.calls('s').length, 1, 'a task waiting on an answer does not wake again') + assert.equal(app.calls('b').length, 1) + assert.equal(app.calls('war').length, 0) await app.ctx.taskDrive.consider() - assert.equal(app.calls('s-war').length, 1) - assert.equal(app.calls(bobSession).length, 1) + assert.equal(app.calls('s').length, 1) + assert.equal(app.calls('b').length, 1) }) -test('a live group round defers the member wake until the round is over', async (t) => { +test('a live group round does not turn the wake into a group turn', async (t) => { const app = await start(t) app.rt.roomProgress.set('round-1', { roomId: 'war' }) await app.track('a', { tasks: [ { title: 'Ship the note', overall: true, group: 'War' }, { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'urgent' }, ] }) + assert.equal(app.calls('s').length, 1) + assert.equal(app.calls('s')[0].source.role, 'task') assert.equal(app.calls('s-war').length, 0) - app.rt.roomProgress.delete('round-1') - await app.ctx.taskDrive.consider() - assert.equal(app.calls('s-war').length, 1) assert.equal(app.calls('war').length, 0) }) @@ -370,11 +369,9 @@ test('a board card that is not a conversation task does not wake', async (t) => assert.equal(app.calls('s').length, 0) }) -// #1182: one planner, the assigned card is claimed, and the wake is a group turn -// whose reply is posted into the room. Red on 2521aab: a second Main records the -// overall task, the subtask stays open, and the wake is role `task` so the reply -// never reaches the room. A member's own room card stays allowed. -test('only the task lead assigns, the card is claimed, and the wake is a group turn (#1182)', async (t) => { +// #1182: one planner, and the assigned card is claimed. #1205: the wake is the +// assignee's own chat. The reply stays there. A member's own room card stays allowed. +test('only the task lead assigns, the card is claimed, and the assignee is woken in its own chat (#1205)', async (t) => { const app = await start(t) await assert.rejects(app.rt.updateRoom('war', { taskLead: 'b' }, 's'), /Only the user, a Main Bot, or the admin/) app.rt.state.mainBotIds = ['a', 'b'] @@ -401,34 +398,75 @@ test('only the task lead assigns, the card is claimed, and the wake is a group t assert.match(planned, /overall/) const child = Object.values(app.rt.state.tasks).find(task => task.title === 'Bob writes') assert.equal(child.status, 'claimed') - const sessionId = room.sessions.b - assert.equal(typeof sessionId, 'string') - const wake = app.calls(sessionId).at(-1) - assert.equal(wake.source.role, 'room') - assert.equal(wake.source.taskDrive, true) - assert.equal(wake.source.roomId, 'war') + assert.equal(room.sessions.b, undefined, 'assigning does not open a group session') + const wake = app.calls('b').at(-1) + assert.equal(wake.source.role, 'task') + assert.equal(wake.source.taskDrive, undefined) + assert.equal(wake.source.roomId, undefined) const text = wake.content.map(block => block.text).join('\n') - assert.match(text, /\[Group chat "War" · your task\]/) + assert.match(text, /\[Continue task\]/) assert.match(text, new RegExp(`Continue ${child.id} \\[urgent\\] Bob writes`)) assert.match(text, /do not wait for a confirmation/) - assert.match(text, /never in your own chat/) + assert.match(text, /do not @ a Bot to hand the work on/) + assert.doesNotMatch(text, /never in your own chat/) + assert.match(text, /set this card's column and progress with track_tasks/) + assert.match(text, /do not post a status in the group/) + assert.doesNotMatch(text, /Update it with track_tasks as it moves/) + assert.match(text, /Task board \(assign, claim, and progress live here/) + assert.match(text, /Ship the note/) + assert.match(text, /Bob writes/) assert.equal(app.calls('war').length, 0, 'the driver does not post the cue into the room') - assert.equal(app.calls('b').length, 0, 'the subtask does not wake the own chat') + assert.equal(app.calls('b').length, 1) const linked = await app.track('b', { tasks: [{ title: 'Bob extra', parent: 'Ship the note', group: 'War' }] }) assert.match(linked, /Bob extra/) - assert.equal(app.calls(sessionId).length, 1, 'linking a subtask does not queue a second wake') + assert.equal(app.calls('b').length, 1, 'linking a subtask does not queue a second wake') + + app.beginWake('b') + app.emit('b', 'assistant/message', { message: { content: [{ type: 'text', text: 'Which file should the note use?' }] } }) + app.emit('b', 'turn/end', { reason: { kind: 'completed' } }) + await app.ctx.taskDrive.settled() + assert.equal(app.calls('war').length, 0, 'the own-chat reply is not posted into the group') + assert.equal(app.calls('b').length, 1, 'a wake that did not move the board does not start another') +}) - app.beginWake(sessionId) - app.emit(sessionId, 'assistant/message', { message: { content: [{ type: 'text', text: 'Which file should the note use?' }] } }) - app.emit(sessionId, 'turn/end', { reason: { kind: 'completed' } }) +test('progress stays on the card and the next assignee is woken without a group post (#1205)', async (t) => { + const app = await start(t) + const planned = await app.track('a', { tasks: [ + { title: 'Ship the note', overall: true, group: 'War' }, + { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'high' }, + ] }) + const write = app.idOf(planned, 'Write the draft') + assert.equal(app.calls('s').length, 1) + assert.equal(app.calls('s')[0].source.role, 'task') + assert.equal(app.calls('war').length, 0) + assert.equal(app.calls('b').length, 0) + + app.beginWake('s') + const moved = await app.track('s', { tasks: [{ id: write, column: 'in-progress', progress: 40, note: 'drafting' }] }) + assert.match(moved, /40/) + assert.equal(app.rt.state.tasks[write].progress, 40) + assert.equal(app.rt.state.tasks[write].note, 'drafting') + assert.equal(app.calls('s').length, 1, 'updating the open card does not queue another wake') + assert.equal(app.calls('war').length, 0, 'progress is not a group post') + + const handed = await app.track('a', { tasks: [ + { title: 'Check the facts', parent: 'Ship the note', assignee: 'BobBot', group: 'War', priority: 'urgent' }, + ] }) + const check = app.idOf(handed, 'Check the facts') + assert.equal(app.rt.state.tasks[check].status, 'claimed') + assert.equal(app.calls('b').length, 1, 'the next card wakes its assignee') + assert.equal(app.calls('b')[0].source.role, 'task') + assert.match(app.text('b'), new RegExp(`Continue ${check} \\[urgent\\] Check the facts`)) + assert.equal(app.calls('war').length, 0, 'the handoff does not open a group round') + assert.equal(app.rt.state.rooms.war.sessions.b, undefined) + + await app.track('s', { tasks: [{ id: write, column: 'done', progress: 100 }] }) + app.emit('s', 'turn/end', { reason: { kind: 'completed' } }) await app.ctx.taskDrive.settled() - for (let i = 0; i < 20 && app.calls('war').length === 0; i += 1) await new Promise(resolve => setTimeout(resolve, 10)) - assert.equal(app.calls('war').length, 1, 'the reply is posted into the group') - assert.match(app.text('war'), /Which file should the note use\?/) - assert.equal(app.calls('war')[0].source.role, 'post') - assert.equal(app.calls('war')[0].source.late, true) - assert.equal(app.calls(sessionId).length, 1, 'a wake that did not move the board does not start another') + assert.equal(app.calls('war').length, 0, 'finishing the card does not post into the group') + assert.equal(app.calls('b').length, 1, 'Bob was already woken when the card was written') + assert.equal(app.calls('s').length, 1, 'Writer has no further card') }) test('a Cordis context provides taskDrive instead of assigning it', async (t) => { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index b52255fcb..d1b4c9094 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -39,7 +39,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | | `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | | `core/src/client/delivery-view.js` | The **Delivery** view of the task dialog (#1173): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, cards that link out, the verdict/CI badge, and the header numbers. It polls `actions.deliveryBoard` every 60s, shows `as of` with the snapshot time, and replaces the board with one honest line when there is no GitHub connection. Read-only: it renders, it never writes. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress. The dialog has two tabs (#1173), **Tasks** and **Delivery**; the Delivery view is repository-wide, so its Bot rail is not rendered and it cannot be pointed at one Bot. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress. The dialog has two tabs (#1173), **Tasks** and **Delivery**; the Delivery view is repository-wide, so its Bot rail is not rendered and it cannot be pointed at one Bot. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | @@ -139,7 +139,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A **Tasks** row sits under Inbox. It opens a dialog: a left rail of the viewer's own Bots (a child indented under the Bot that created it) and, below that, groups the viewer administers (human role admin, or they own the Bot in `room.admin`; face-to-face rooms stay out). - The board has three columns, **Not started**, **In progress** and **Done**, filled from `actions.tasks` (`src/host/tasks.js`, [core-host](core-host.md#bot-task-board-1058)). A card shows a short id, a priority chip, the title, an optional note, the Bot's name and a progress percent. **Start** and **Complete**, or a drop on a column, call `actions.taskUpdate`. A card the viewer does not own (another member's Bot in a group view) has neither the buttons nor a drag. -- Choosing a group loads `tasks { roomId }` and lanes the cards by Bot. An overall group task (`kind: 'group'`) is marked **Group task**, and a subtask in the same column is indented under it (`columnTree`). A subtask whose overall task is in another column carries **Part of {title}**. Choosing a Bot filters that Bot's own list, including a child. **New task** adds one on the selected Bot. The host decides who may read or move a task; the dialog only hides the actions. A card waiting on input shows that question. +- Choosing a group loads `tasks { roomId }` and lanes the cards by Bot. An overall group task (`kind: 'group'`) is marked **Group task**, and a subtask in the same column is indented under it (`columnTree`). A subtask whose overall task is in another column carries **Part of {title}**. The group view says assigning, claiming, and progress live on this board, and that @ only wakes someone. Choosing a Bot filters that Bot's own list, including a child. **New task** adds one on the selected Bot. The host decides who may read or move a task; the dialog only hides the actions. A card waiting on input shows that question. ### Phone-width layout (#362, step 1) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index d0c689faf..cdbe070b2 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -100,6 +100,12 @@ A member whose Bot record carries `external` is spoken for by a supervised subpr A member's group Session reads the group's fixed rules from a system-prompt section, `bot-room` (order 10150, after DSH's own sections so the member's group Session and its own chat share the longest system-prompt prefix), not from each turn's message. `groups.js` `standingText` renders it from room settings only: the members line (or the face roster), `@Name` calls, the notice, the admin note or the face lead / member note, the face rule for lines that name no Bot (`routeRules`: the member's default, the exception for a line to everyone, other homes' Bots answering in their own home, never explain a pass), and `ROOM_REPLY`. DSH renders sections on every model step and re-projects them after a checkpoint or in a new part, so the rules are always in front of the model while the history keeps one copy per change instead of one per turn. The own chat and the group's relay Session render no section (`''`). Each turn's message keeps the round (earlier lines, this round, references, who is still working, speaking order, position), a short `routeNote` (this line: to everyone, the lead's or "Default to [PASS]", who spoke last, who already answered), and `ROOM_REPLY_NOW` (or `ROOM_REPLY_FORCED`). `room.noticeSeen[botId]` keeps a 16-hex SHA-256 mark of the notice a member last saw, so its next turn says only that the notice changed or was cleared. DSH runtime context (`systemPrompt.context`) is not used: its `runtime-context` user messages are not a no-request kind in `turn-origin.js`. Covered by `core/test/rounds.test.js` and `core/test/context-sharing.test.js`. +### Room context budget (#1203) + +A group turn does not hand the model the raw ring buffer. `src/host/room-context.js` `fitRoomLines` takes the lines the count caps already chose (`ROOM_FIRST_LOOK` 12, `ROOM_DELTA_MAX` 24, and `ROOM_EARLIER_MAX` 8 of those from before the round) and keeps a newer-first slice of at most `ROOM_CONTEXT_BUDGET` characters (4000). The budget is characters because the measured essays are mostly CJK, about one token per character. A long bot line that reads as CI, merge-queue, or review narration (`statusShaped`: at least 400 characters and two of the status hints) is collapsed to a one-line stub that keeps the opening and up to four `@` names. The stored log line is not rewritten, and a person's line is not collapsed. When lines are left out, the turn says so and points at `read_group_chat` for an exact earlier line. `read_group_chat` still returns the log, not the stub. + +The open task board for that room is a compact snapshot in the same turn (`formatRoomBoard`): the newest overall task, then its subtasks and any room card that is not under a different overall task, up to eight cards. Done cards stay off. Titles and notes are one line, capped, with `<` and `>` removed. A face-to-face room has no snapshot and keeps `@Name` as how its lead gives a part. The standing section does not carry the snapshot or the transcript, so it still changes only when the group settings change. In a group that has a board, `ROOM_REPLY_BOARD` and the task-lead note say assigning, claiming, and progress are `track_tasks` writes (overall task, subtask, assignee, column, progress), `@Name` only wakes, and a status the board already shows is `[PASS]`. A task-drive wake in the group says to set that card's column and progress, and appends the same snapshot after its cue. Covered by `core/test/room-context.test.js`, `core/test/rounds.test.js`, and `core/test/task-drive.test.js`. + ### Group loop guards (#557) `post_to_group` has no cap on the rounds Bots start in one group between the user's lines. The old `ROOM_BOT_ROUNDS` (3) gate was turned off by owner decision 2026-10-21 07:34 Asia/Shanghai: it refused a recurring scheduled task's fourth post with a tool result only, so owner-set runs stopped overnight with nothing shown in the group. A saved room may still carry `botRounds`; nothing reads it, and `channel.js` keeps it out of the roster. The guards that stay: `MAX_HOPS` (6) ends a Bot-to-Bot chain (`message_bot`, and `post_to_group` from a relayed turn); `MAX_UNREAD_REQUESTS` (10) per Bot chat; `ROOM_TURNS_EACH` / `ROOM_ADMIN_TURNS_EACH` (2 / 4) per member per round, except the room's admin when a member names it with `@`: that call is never dropped by the cap (#804), because it is a report to the coordinator rather than a chain, and the round's own turn limit still bounds the round; `ROOM_LATE_ROUNDS` (10) late replies answered before the user speaks again; and a Bot cannot `post_to_group` into the room it is speaking in. Face-to-face rooms take the same path. Covered by `core/test/rounds.test.js`. @@ -180,7 +186,7 @@ Each Bot's work sits on the same `state.tasks` records. A column is read from th `track_tasks` is the conversation path. The calling Bot creates or updates only its own tasks (`source: 'conversation'`, `requesterId` the Bot's owner, `origin.sessionId` the turn, `origin.roomId` the group when the turn is in one). The same title in the same group updates the open task instead of adding another. The prompt tells the Bot to record work the user or a group actually handed it, to continue the unfinished task the tool names next, and to pass `ask` when it cannot continue without an answer. -A group job is one overall task plus a subtask per Bot (#1099). The overall task is `kind: 'group'`, owned by the Bot that records it, and tagged with `origin.roomId`. A group keeps one open overall task; recording another updates that one. A subtask sets `parentId` to that task. One Bot divides the board (#1182): `room.taskLead` when that Bot is still a member, otherwise the group admin. A face-to-face room has no board, so it has no task lead. Another Main Bot does not plan. The user, a Main Bot, or the group's admin sets the lead with `update_group` (`task_lead`, `none` clears it) or `update-room` (`taskLead`); group settings shows **Make task lead** / **Use the admin** on a group that lives here. Assigning a subtask to another member claims it (`open` → `claimed`) so the assignee starts without a confirmation. The requester stays that Bot's owner, so the lead still cannot move the card through `task-update`. A member updates the card assigned to it, records its own room task, or links its own subtask to the overall task. Only the lead records the overall task or assigns a subtask to someone else. Reading the list orders unfinished work by priority (urgent, high, normal, low) and prefers an in-progress task when the priority is the same. `ask` walks the task to working and then to the existing input wait (`waitingFor: 'input'`); the reply tells the Bot to ask in its group reply when the task belongs to a group, and to call `ask_user` only in its own chat. `answer` resumes that wait. The question is the same record the inbox already shows for a blocked task. +A group job is one overall task plus a subtask per Bot (#1099). The overall task is `kind: 'group'`, owned by the Bot that records it, and tagged with `origin.roomId`. A group keeps one open overall task; recording another updates that one. A subtask sets `parentId` to that task. One Bot divides the board (#1182): `room.taskLead` when that Bot is still a member, otherwise the group admin. A face-to-face room has no board, so it has no task lead. Another Main Bot does not plan. The user, a Main Bot, or the group's admin sets the lead with `update_group` (`task_lead`, `none` clears it) or `update-room` (`taskLead`); group settings shows **Make task lead** / **Use the admin** on a group that lives here. Assigning a subtask to another member claims it (`open` → `claimed`) so the assignee starts without a confirmation, and wakes that assignee in its own chat (#1205); the assignment does not open a group round. The assignee then sets the column to in-progress with `track_tasks`; a chat line does not assign, claim, or record progress. The group board shows that above the columns. The requester stays that Bot's owner, so the lead still cannot move the card through `task-update`. A member updates the card assigned to it, records its own room task, or links its own subtask to the overall task. Only the lead records the overall task or assigns a subtask to someone else. Reading the list orders unfinished work by priority (urgent, high, normal, low) and prefers an in-progress task when the priority is the same. `ask` walks the task to working and then to the existing input wait (`waitingFor: 'input'`); the reply tells the Bot to call `ask_user` for the user, and to post one short question in the group only when a person there has to decide. `answer` resumes that wait. The question is the same record the inbox already shows for a blocked task. A task may name where it came from (`target`, #1154): `task-create` and `track_tasks` take `https://github.com///issues|pull/` or `/#` and store `{ owner, repo, number, href }` with the href always on the `/issues/` form; anything else is refused (`bot/invalid`). The target is optional, and a task without one behaves exactly as before: no `writeBack`, nothing extra on the card, no extra wake. @@ -196,11 +202,11 @@ The module is read-only by construction: every request is a `GET`, and the paylo ### Task-driven turns (#1109) -Unfinished conversation tasks wake the owning Bot. `src/host/task-drive.js` runs after a board write and after a turn ends. For each owner, the next task is the one `compareForWork` would name: `open`, `claimed`, or `working`, not `waitingFor: 'input'`, and not an overall group card (`kind: 'group'`). A decision whose comment is still `pending` counts as work even when the task is already `accepted`, and the wake says where to post it (#1154). That card is the plan; the subtasks are the work. One drive pass wakes every such owner, so a group assignment starts each member's own subtask in that same pass. One Bot still receives only its own next task. A task with no group wakes the Bot's own chat. The wake is a follow-up whose header is `[Continue task]` (`delivery.js`, role `task`). It names the task id, priority, and title. It is not a group post, so the reply is not posted back and does not start a round. A subtask wakes that Bot's Session in the group, opening the Session the first time (`memberSession`) so the driver does not wait for someone to speak there (#1182). That wake is a room turn (`role: 'room'`, `taskDrive: true`, header `[Group chat "…" · your task]`). The cue says to start the claimed card and not to wait for a confirmation, and to ask a blocking question in the reply, not in the Bot's own chat. There is no round waiter, so when the turn ends the reply is posted into the room as a late line: a late line calls only the Bots it names, and the driver itself does not post the cue or open a round. +Unfinished conversation tasks wake the owning Bot. `src/host/task-drive.js` runs after a board write and after a turn ends. For each owner, the next task is the one `compareForWork` would name: `open`, `claimed`, or `working`, not `waitingFor: 'input'`, and not an overall group card (`kind: 'group'`). A decision whose comment is still `pending` counts as work even when the task is already `accepted`, and the wake says where to post it (#1154). That card is the plan; the subtasks are the work. One drive pass wakes every such owner, so a group assignment starts each member's own subtask in that same pass. One Bot still receives only its own next task. A task with no group wakes the Bot's own chat. The wake is a follow-up whose header is `[Continue task]` (`delivery.js`, role `task`). It names the task id, priority, and title. It is not a group post, so the reply is not posted back and does not start a round. A group subtask wakes that same own chat (#1205). It does not open the Bot's group Session and it does not start a round. The cue says to set the card's column and progress with `track_tasks`, not to post a status in the group, and not to wait for a confirmation. A question a person has to decide is one short post in the group; the wake's own reply stays in the chat and is not posted into the room. Completing or updating the card is what wakes the next assignee. The wake text also carries that room's compact board snapshot (#1203), the same one a round turn shows, so the Bot reads progress from the board. A Bot @ in the group whose open card already shows that line is a pass (`boardCoversCue` in `room-context.js`, applied in the round before a member turn): the round records it as passed and does not start a turn. A question, a line the card does not already say, and a person's @ still get a turn. Tests reach the driver through `ctx.taskDrive` (`now`, `settled`, `consider`). That handle is a Cordis service registered with `provide`, the same path as `ctx.flybot`. A live context refuses a bare property write, which stopped startup before any Bot loaded (#1120). -The driver skips a Session that already has a turn, and a second wake while the first is still queued or in progress. A queued wake that has not started is replaced when a different task becomes that Bot's next one. A live group round defers member wakes until the round's turn ends; the parallel wake is the assignment pass, not an extra turn beside a round. A wake that ends with the same board (status, progress, priority, note, title, who is waiting, and the state and target of a pending comment — not `updatedAt`, which a no-op `track_tasks` call still bumps) waits 30s, then 2min, 10min, and 30min before trying that Session again. A change on the board, or a person speaking in that Session, clears the wait. A task that is waiting on an answer does not wake: the turn that called `ask` asks once, and the driver does not loop. The idle rule still forbids inventing work; unfinished work the user or a group already handed the Bot counts as that task. Tests: `core/test/task-drive.test.js`. +The driver skips a Session that already has a turn, and a second wake while the first is still queued or in progress. A queued wake that has not started is replaced when a different task becomes that Bot's next one. A live group round does not defer that wake: it is not a group turn, so it does not sit beside the round. The parallel wake is the assignment pass. A wake that ends with the same board (status, progress, priority, note, title, who is waiting, and the state and target of a pending comment — not `updatedAt`, which a no-op `track_tasks` call still bumps) waits 30s, then 2min, 10min, and 30min before trying that Session again. A change on the board, or a person speaking in that Session, clears the wait. A task that is waiting on an answer does not wake: the turn that called `ask` asks once, and the driver does not loop. The idle rule still forbids inventing work; unfinished work the user or a group already handed the Bot counts as that task. Tests: `core/test/task-drive.test.js`. ### Work view step (#546) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 7a2c231ad..c3e45486c 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -869,7 +869,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'You have {version}.': '当前版本 {version}。', 'The update did not finish: {reason}': '更新没有完成:{reason}', "What's new": '更新说明', -@@ -360,9 +600,71 @@ +@@ -360,9 +600,72 @@ 'DSH update': 'DSH 更新', 'DSH {version}': 'DSH {version}', 'Restart DSH to start using DSH {version}.': '重启 DSH 后开始使用 DSH {version}。', @@ -928,6 +928,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Complete task': '完成任务', + 'Add task': '添加任务', + 'Group task': '群任务', ++ 'Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.': '派活、领活和进度都在这块看板上:一个总任务、每个部分一条子任务、负责人、列和进度。@ 只是叫醒对方。', + 'Part of {title}': '属于 {title}', + 'Task title': '任务标题', + Progress: '进度', @@ -3804,19 +3805,20 @@ diff --git a/src/host/prompt.js b/src/host/prompt.js + '- For "[Group chat …]", "[Message from …]", "[Reply from …]", or "[Continue task]" messages, do not write plans or progress updates: use tools silently, then reply with the answer. Simple answers do not need progress messages.', '- Offer choices with ask_user (a tap-to-answer card) instead of long option lists in text. Calling it ends your turn at once, so write everything you want to say before the call, in the same reply. The answer arrives as the user\'s next message.', '- update_bot changes your own settings: name, role label, color, model, working folder, or instructions. Leave out bot to change yourself. Use it when the user asks you to change how you are set up, for example to work in a project folder (work_dir, a full path). Only a Main Bot can change other Bots; when another Bot needs changing, ask a Main Bot.', - '- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. When someone there asks you to do something, do the work in that turn: the group goes on without waiting, and your final reply is posted when you finish. Any member can hand work to another with @Name, and the one that asked answers the result. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.', +- '- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. When someone there asks you to do something, do the work in that turn: the group goes on without waiting, and your final reply is posted when you finish. Any member can hand work to another with @Name, and the one that asked answers the result. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.', ++ '- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. When someone there asks you to do something, do the work in that turn: the group goes on without waiting, and your final reply is posted when you finish. In a group that has a task board, @Name only wakes a member; it does not assign work or record progress. Assigning, claiming, and progress there are the task board (overall task, subtask, assignee, column, and progress) through track_tasks. A face-to-face room has no board: there, @Name still gives a part. The one that asked answers the result. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.', '- When you need a key, token, or password, call request_secret: the user types it into a secure card and you never see it. Never ask the user to paste one into the chat. Use it through its environment variable ($DSH_SECRET_ in bash, $env:DSH_SECRET_ in PowerShell) and never print it. list_secrets shows which keys exist.', + '- For GitHub work, use the GitHub connector tools (mcp__github__*) first. Fall back to $DSH_SECRET_GITHUB_TOKEN with gh or git in the shell only for what those tools cannot do, such as git push.', '- web_search results say which search backend answered. If web_search fails, never guess URLs or present pages you remember as search results: use a browser tool if you have one, otherwise tell the user that search is unavailable and the reason it gave.', '- Your conversations are long-lived. When one grows long, either its older messages are condensed in place into a summary you wrote (a "[Checkpoint N]" message), or it moves on to a fresh part that opens with a handoff note you wrote and your latest messages (a "[Handoff · part N]" message). What leaves your working context stays saved. When you need the exact earlier wording or a detail the summary or note does not show in full (a full path, a figure, every item of a complete list or a final summary), call read_own_chat with words to look for, or with part N to read the note that closed an earlier part; never fill such a detail in from memory. Each line it returns starts with an anchor such as [part 2 #1234 · 10-08 14:22]; pass the anchor as around to read what was said around it.', '- Reply in the user\'s language.', + '- You keep a task list with track_tasks, and you use it to plan work and carry it, not only to show a status. When the user gives you work, or a group hands you work, record it there. Do not record work nobody asked for.', -+ '- In a group, the task lead records one overall task for the whole job (overall: true) and a subtask for each Bot that has a part (parent is that overall task\'s id or title, assignee is that Bot\'s name). The task lead is the Bot picked for that room, or the group admin when none is picked. Another Bot, including another Main Bot, does not divide the board. A member updates the card assigned to it, or links its own subtask with parent set to the overall task. One group has one open overall task; recording it again updates that same task.', -+ '- When a card is assigned to you, start the work. Do not wait for the user to confirm before you begin.', -+ '- Each task has a short title, a column (not-started, in-progress, done), how far along from 0 to 100, a priority (low, normal, high, urgent) and a short note. Update that same task as it moves, so the column and progress stay current.', ++ '- In a group, the task lead records one overall task for the whole job (overall: true) and a subtask for each Bot that has a part (parent is that overall task\'s id or title, assignee is that Bot\'s name). That record is the assignment. A chat message, including @Name, does not assign it. The task lead is the Bot picked for that room, or the group admin when none is picked. Another Bot, including another Main Bot, does not divide the board. A member updates the card assigned to it, or links its own subtask with parent set to the overall task. One group has one open overall task; recording it again updates that same task.', ++ '- When a card is assigned to you, claim it on the board: set its column to in-progress with track_tasks, then start the work. Do not wait for the user to confirm before you begin, and do not announce the claim in chat.', ++ '- Each task has a short title, a column (not-started, in-progress, done), how far along from 0 to 100, a priority (low, normal, high, urgent) and a short note. Update that same task as it moves, so the column and progress stay current. That update is the progress. Do not post a status essay in the group.', + '- When you are given work, or you already have unfinished tasks from work you were given, continue the task track_tasks names next instead of inventing new work. Call track_tasks with no tasks to read the list. It prefers a higher priority (urgent, then high, then normal, then low), and an in-progress task before one that has not started when the priority is the same.', -+ '- A "[Continue task]" message is unfinished work you were already given. Do that task now and update it with track_tasks. Do not invent another job, send a status, or start a group round. In a group, post only when that task needs the group to see a result.', -+ '- When you cannot continue without an answer, pass ask with the one question on that task. Then ask it: in a group, in your reply there (never ask_user in your own chat); in your own chat, ask_user. When the answer arrives, pass answer with what they said and update the task. Do not leave the task waiting without that question.', ++ '- A "[Continue task]" message is unfinished work you were already given, including a group card. Do that task now and update its column, progress, and note with track_tasks. Do not invent another job, send a status, or start a group round. Post in the group only when a person has to decide.', ++ '- When you cannot continue without an answer, pass ask with the one question on that task. On a group card, worked in your own chat, call ask_user for the user, and post one short question in the group only when a person there has to decide. On your own chat\'s other tasks, ask_user. When the answer arrives, pass answer with what they said and update the task. Do not leave the task waiting without that question.', ].join('\n') const MAIN_RULES = [ @@ -8388,7 +8390,7 @@ diff --git a/test/prompt.test.js b/test/prompt.test.js ] for (const id of ['main', 'worker']) { for (const rule of rules) { -@@ -43,3 +43,35 @@ test('the group relay and non-Bot sessions do not get progress instructions', (t +@@ -43,3 +43,40 @@ test('the group relay and non-Bot sessions do not get progress instructions', (t assert.doesNotMatch(prompt('room'), /progress|milestone/) assert.equal(prompt('outside'), '') }) @@ -8416,7 +8418,12 @@ diff --git a/test/prompt.test.js b/test/prompt.test.js + assert.match(prompt('worker'), /overall: true/) + assert.match(prompt('worker'), /the task lead records one overall task/) + assert.match(prompt('worker'), /Do not wait for the user to confirm/) -+ assert.match(prompt('worker'), /never ask_user in your own chat/) ++ assert.doesNotMatch(prompt('worker'), /hand work to another with @Name/) ++ assert.match(prompt('worker'), /@Name only wakes a member; it does not assign work or record progress/) ++ assert.match(prompt('worker'), /set its column to in-progress with track_tasks/) ++ assert.match(prompt('worker'), /That update is the progress/) ++ assert.match(prompt('worker'), /post one short question in the group only when a person there has to decide/) ++ assert.doesNotMatch(prompt('worker'), /never ask_user in your own chat/) + assert.match(prompt('worker'), /pass ask with the one question/) + assert.equal(prompt('worker').split('overall: true').length - 1, 1) + assert.doesNotMatch(prompt('worker'), /coordinator/, 'the coordinator wording belongs to the Main Bot') diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index d808d5421..7f41c26af 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -1422,7 +1422,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js + for (const task of ordered) { + if (task.waitingFor !== 'input' || !task.inputRequest?.question) continue + cues.push(inGroup -+ ? `Waiting ${task.id}: ask the group in your reply: ${task.inputRequest.question}` ++ ? `Waiting ${task.id}: the card is waiting. Call ask_user for the user. Post one short question in the group only when a person there has to decide: ${task.inputRequest.question}` + : `Waiting ${task.id}: call ask_user to ask the user: ${task.inputRequest.question}`) + } + if (withNext) { @@ -1443,7 +1443,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js + + ctx.tools.register(defineTool({ + name: 'track_tasks', -+ description: 'Read or update tasks on the board. Omit tasks to read your list: the first lines name the unfinished task to continue (higher priority first) and any task waiting on an answer. Pass tasks to create or update your own. In a group, the task lead (the Bot picked for the room, or the admin when none is picked) records one overall task (overall: true) and a subtask per Bot (parent is the overall task\'s id or title, assignee is that Bot\'s name). Another Main Bot does not plan. A member updates the card assigned to it, or links its own subtask with parent. Do not record a second plan. Match an id, or the title of a task still open. Each task has a title, a column (not-started, in-progress, done), progress from 0 to 100, a priority (low, normal, high, urgent) and a note. Pass ask when you need an answer before the task can continue, and answer when the user or the group has answered. In a group, ask in your reply there, not in your own chat. In a group the task is tracked for that group.', ++ description: 'Read or update tasks on the board. Assigning work, claiming it, and recording progress are this tool, not a chat message. Omit tasks to read your list: the first lines name the unfinished task to continue (higher priority first) and any task waiting on an answer. Pass tasks to create or update your own. In a group, the task lead (the Bot picked for the room, or the admin when none is picked) records one overall task (overall: true) and a subtask per Bot (parent is the overall task\'s id or title, assignee is that Bot\'s name). Another Main Bot does not plan. A member claims the card assigned to it by setting its column to in-progress, updates that card\'s column and progress as it moves, or links its own subtask with parent. Do not record a second plan. Match an id, or the title of a task still open. Each task has a title, a column (not-started, in-progress, done), progress from 0 to 100, a priority (low, normal, high, urgent) and a note. Pass ask when you need an answer before the task can continue, and answer when the user or the group has answered. A group card is worked in your own chat. Post one short question in the group only when a person has to decide. In a group the task is tracked for that group.', + parameters: { + tasks: { + type: 'array', @@ -1462,7 +1462,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js + overall: { type: 'boolean', description: 'Record this as the group\'s one overall task. Only the room\'s task lead may set it (the group admin, when no lead is picked). A later call updates that same open task.' }, + parent: { type: 'string', description: 'Id or title of the overall group task this subtask belongs to.' }, + assignee: { type: 'string', description: 'Name of the Bot that owns this subtask. Only the room\'s task lead may name another member. The card is claimed, and that Bot starts the work without waiting for a confirmation.' }, -+ ask: { type: 'string', description: 'The question you need before this task can continue. The task waits. In your own chat, call ask_user; in a group, ask in your reply.' }, ++ ask: { type: 'string', description: 'The question you need before this task can continue. The task waits. Call ask_user in your own chat. For a group card, post one short question in the group only when a person has to decide.' }, + answer: { type: 'string', description: 'What the user or the group answered. Pass it to resume a task that was waiting.' }, + target: { type: 'string', description: 'Where a decision on this task is written back: a GitHub issue or pull request, as https://github.com/owner/repo/issues/12 or owner/repo#12. Optional.' }, + postResult: { diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index cc876f87a..c375a5e93 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1183,7 +1183,7 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js diff --git a/test/rounds.test.js b/test/rounds.test.js --- a/test/rounds.test.js +++ b/test/rounds.test.js -@@ -114,6 +114,8 @@ const waitFor = async (check) => { +@@ -114,6 +114,8 @@ return undefined } const turnFor = (sent, ...earlier) => waitFor(() => sent.find(entry => !earlier.includes(entry) && entry.message.source.role === 'room')) @@ -1192,7 +1192,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a new group Session waits for capabilities before its first member prompt', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() -@@ -180,6 +180,69 @@ test('a message the composer queues mid-round joins the round and leaves the Ses +@@ -180,6 +180,69 @@ assert.deepEqual(inboxOf(room.id).nextTurn, []) }) @@ -1262,7 +1262,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a plain message steered mid-round is answered by everyone, admin first, inside the round', async (t) => { const { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent } = start() t.after(stop) -@@ -253,32 +253,6 @@ test('in admin mode a plain mid-round message goes to the admin', async (t) => { +@@ -253,32 +253,6 @@ assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer', 'User', 'Chief']) }) @@ -1295,7 +1295,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message with an image or a chat reference waits for its own round, and so does what follows it', async (t) => { const { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent } = start() t.after(stop) -@@ -300,19 +300,26 @@ test('a message with an image or a chat reference waits for its own round, and s +@@ -300,19 +300,26 @@ assert.equal(inboxOf(room.id).nextStep.length, 1) }) @@ -1329,7 +1329,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js }) test('a stopped round leaves a waiting message for the next round', async (t) => { -@@ -331,6 +331,377 @@ test('a stopped round leaves a waiting message for the next round', async (t) => +@@ -331,6 +331,377 @@ assert.equal(inboxOf(room.id).nextTurn.length, 1) }) @@ -1707,7 +1707,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message sent between rounds still opens a full round', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent } = start() t.after(stop) -@@ -347,3 +347,1127 @@ test('a message sent between rounds still opens a full round', async (t) => { +@@ -347,3 +347,1274 @@ const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) @@ -2519,10 +2519,12 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + assert.match(standing, /^# This group chat/) + assert.ok(standing.includes(`Group notice (shared instructions for every member):\n${notice}`), 'the notice is in the section') + assert.match(standing, /Members: the user, /) -+ assert.match(standing, /Writing "@Name" calls that member next/) ++ assert.match(standing, /Writing "@Name" wakes that member/) ++ assert.match(standing, /It does not assign work or record progress/) ++ assert.doesNotMatch(standing, /one short handoff line/) + assert.match(standing, /Reply \[PASS\] to stay silent/) + assert.ok(!text.includes('Rule one'), 'no turn repeats the notice') -+ assert.doesNotMatch(text, /Members: the user|Writing "@Name" calls|Group notice|You are this group's admin/) ++ assert.doesNotMatch(text, /Members: the user|Writing "@Name"|Group notice|You are this group's admin/) + assert.match(text, /Speaking order for this round/) + assert.match(text, /Reply now with your message for the group, no name prefix, or \[PASS\] to stay silent\.$/) + } @@ -2596,6 +2598,8 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + assert.match(standing, /Default to \[PASS\]: exactly one Bot answers a line like this, and the first Bot that answers ends it/) + assert.match(standing, /A line addressed to everyone .* is the exception/) + assert.match(standing, /\[PASS\] posts nothing and nobody sees it: never explain a pass\./) ++ assert.match(standing, /Writing "@Name" calls that member next/) ++ assert.doesNotMatch(standing, /does not assign work or record progress/) + assert.match(text, /This line names no Bot\. Default to \[PASS\]: exactly one Bot answers it; answer only if it is for you, by the rule for such lines in your instructions for this group\./) + assert.doesNotMatch(text, /face-to-face room with people|What members do|never explain a pass|the first Bot that answers ends it|Writing "@Name" calls/) + } @@ -2835,3 +2839,146 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + assert.match(text, /Called, but no answer: Editor\./, 'a member called mid-round that said nothing is reported as called') + assert.doesNotMatch(text, /Stayed silent: [^.]*Editor/, 'never in the sentence for a member nobody called') +}) ++ ++// #1203: message-count caps still let a handful of CI essays fill the first look. ++// The turn the model reads stays inside a character budget, those essays collapse, ++// and the open board is the progress. The stored log keeps the essay. ++test('#1203 a group turn budgets room context and reads progress from the board', async (t) => { ++ const { call, emit, userMessage, relay, answer, stop, sent, ctx, rt } = start() ++ t.after(stop) ++ const chief = (await call('state', {})).value.mainBotId ++ const writer = (await call('create-bot', { name: 'Writer' })).value ++ const room = (await call('create-room', { name: 'R1203', members: [chief, writer.id] })).value ++ await call('update-room', { id: room.id, mode: 'everyone', notice: 'Keep this notice verbatim.' }) ++ const marker = 'ESSAY_BODY_MARKER_1203' ++ const essay = `CI on tip abcdef12 is green. tests-ok passed. enqueue when mergeable. merge_group is next. PASS at abcdef12. @Chief ${'detail '.repeat(180)}${marker}` ++ assert.ok(essay.indexOf(marker) > 400) ++ await rt.load() ++ const live = rt.roomOf(room.id) ++ live.log = [] ++ live.seq = 0 ++ live.seen = {} ++ for (let seq = 1; seq <= 10; seq += 1) { ++ live.log.push({ seq, who: 'Writer', botId: writer.id, authorId: writer.id, authorKind: 'bot', text: `${essay} ${seq}` }) ++ } ++ live.seq = 11 ++ live.log.push({ seq: 11, who: 'Writer', botId: writer.id, authorId: writer.id, authorKind: 'bot', text: 'short handoff keep me' }) ++ rt.state.tasks ??= {} ++ Object.assign(rt.state.tasks, { ++ 'task-overall': { ++ id: 'task-overall', status: 'open', kind: 'group', title: 'Ship the note', progress: 40, ++ ownerBotId: chief, source: 'conversation', origin: { roomId: room.id }, updatedAt: 2, createdAt: 1, ++ }, ++ 'task-sub': { ++ id: 'task-sub', status: 'claimed', title: 'Writer implements', parentId: 'task-overall', ++ note: 'keep the card \nnext', ownerBotId: writer.id, source: 'conversation', ++ origin: { roomId: room.id }, updatedAt: 3, createdAt: 2, ++ }, ++ 'task-done': { ++ id: 'task-done', status: 'accepted', title: 'FINISHED_MARKER_1203', ownerBotId: writer.id, ++ source: 'conversation', origin: { roomId: room.id }, updatedAt: 4, createdAt: 1, ++ }, ++ }) ++ sent.length = 0 ++ await emit(room.id, userMessage('go on')) ++ const running = relay(room.id, 'r1203') ++ const first = await turnFor(sent) ++ const prompt = promptOf(first) ++ const earlier = prompt.split('Earlier in the group, before this round (context only):\n')[1]?.split('\n\n')[0] ?? '' ++ assert.ok(earlier.length > 0 && earlier.length <= 4000, `earlier room context is ${earlier.length} chars`) ++ assert.match(prompt, /short handoff keep me/) ++ assert.doesNotMatch(prompt, /ESSAY_BODY_MARKER_1203/) ++ assert.match(prompt, /\[status on the board, \d+ chars\]/) ++ assert.match(prompt, /Task board \(assign, claim, and progress live here/) ++ assert.match(prompt, /\[open 40%\] Ship the note/) ++ assert.match(prompt, /Writer \[claimed\] Writer implements — keep the card secret next/) ++ assert.doesNotMatch(prompt, /FINISHED_MARKER_1203/) ++ const standing = standingOf(ctx, first.sessionId) ++ assert.match(standing, /Keep this notice verbatim\./) ++ assert.match(standing, /Do not narrate CI/) ++ assert.match(standing, /assign it on the board with track_tasks/) ++ assert.match(standing, /A chat line does not assign/) ++ assert.match(standing, /does not assign or record progress/) ++ assert.doesNotMatch(standing, /one short handoff line/) ++ assert.doesNotMatch(standing, /hand work to another/) ++ assert.doesNotMatch(prompt, /Keep this notice verbatim/) ++ assert.match(prompt, /Reply now with your message for the group, no name prefix, or \[PASS\] to stay silent\.$/) ++ assert.ok(live.log.some(line => String(line.text).includes(marker)), 'the stored log still has the essay') ++ await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) ++ const second = await turnFor(sent, first) ++ const memberStanding = standingOf(ctx, second.sessionId) ++ await answer(second.sessionId, second.message.source.exchangeId, '[PASS]', 2) ++ await running ++ assert.match(memberStanding, /setting its column to in-progress with track_tasks/) ++ assert.match(memberStanding, /@Name only wakes someone; it does not assign/) ++ assert.doesNotMatch(memberStanding, /one short handoff line/) ++}) ++ ++test('a Bot @ whose open card already shows the line passes without a turn (#1205)', async (t) => { ++ const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() ++ t.after(stop) ++ const chief = (await call('state', {})).value.mainBotId ++ const writer = (await call('create-bot', { name: 'Writer' })).value ++ const room = (await call('create-room', { name: 'R1205', members: [chief, writer.id] })).value ++ await call('update-room', { id: room.id, mode: 'everyone' }) ++ await rt.load() ++ const live = rt.roomOf(room.id) ++ rt.state.tasks ??= {} ++ Object.assign(rt.state.tasks, { ++ 'task-overall': { ++ id: 'task-overall', status: 'open', kind: 'group', title: 'Ship the note', ++ ownerBotId: chief, source: 'conversation', origin: { roomId: room.id }, updatedAt: 1, createdAt: 1, ++ }, ++ 'task-sub': { ++ id: 'task-sub', status: 'working', title: 'Writer implements', progress: 40, note: 'draft is in', ++ ownerBotId: writer.id, source: 'conversation', origin: { roomId: room.id }, updatedAt: 2, createdAt: 1, ++ }, ++ }) ++ const controller = new AbortController() ++ t.after(() => controller.abort()) ++ sent.length = 0 ++ await emit(room.id, botLine(chief, 'Chief', '@Writer Writer implements — draft is in')) ++ const covered = JSON.parse(await relay(room.id, 'r1205-cover', controller.signal)) ++ assert.equal(sent.length, 0, 'the card already shows the line, so Writer is not given a turn') ++ assert.ok(covered.passed.includes('Writer')) ++ ++ sent.length = 0 ++ await emit(room.id, botLine(chief, 'Chief', '@Writer Writer implements the rollback instead')) ++ const redirecting = relay(room.id, 'r1205-redirect', controller.signal) ++ const redirected = await turnFor(sent) ++ await answer(redirected.sessionId, redirected.message.source.exchangeId, 'Leaving the draft.', 4) ++ await redirecting ++ assert.equal(redirected.sessionId, live.sessions[writer.id], 'a title plus a new instruction still opens a turn') ++ ++ sent.length = 0 ++ await emit(room.id, botLine(chief, 'Chief', '@Writer Writer implements, then delete the notes file')) ++ const deleting = relay(room.id, 'r1205-delete', controller.signal) ++ const deleted = await turnFor(sent) ++ await answer(deleted.sessionId, deleted.message.source.exchangeId, 'Deleting the notes file.', 5) ++ await deleting ++ assert.equal(deleted.sessionId, live.sessions[writer.id], 'a title plus another new instruction still opens a turn') ++ ++ sent.length = 0 ++ await emit(room.id, botLine(chief, 'Chief', '@Writer the draft is wrong, redo paragraph two')) ++ const asking = relay(room.id, 'r1205-ask', controller.signal) ++ const asked = await turnFor(sent) ++ await answer(asked.sessionId, asked.message.source.exchangeId, 'Rewriting paragraph two.', 1) ++ await asking ++ assert.equal(asked.sessionId, live.sessions[writer.id], 'a new ask still opens a turn') ++ ++ sent.length = 0 ++ await emit(room.id, botLine(chief, 'Chief', '@Writer which file should the note use?')) ++ const questioning = relay(room.id, 'r1205-question', controller.signal) ++ const question = await turnFor(sent) ++ await answer(question.sessionId, question.message.source.exchangeId, 'The notes file.', 2) ++ await questioning ++ assert.equal(question.sessionId, live.sessions[writer.id]) ++ ++ sent.length = 0 ++ await emit(room.id, userMessage('@Writer Writer implements')) ++ const personRound = relay(room.id, 'r1205-person', controller.signal) ++ const person = await turnFor(sent) ++ await answer(person.sessionId, person.message.source.exchangeId, 'On it.', 3) ++ await personRound ++ assert.equal(person.sessionId, live.sessions[writer.id], 'a person @ still gets an answer') ++}) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 47779c404..f8c374051 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -401,7 +401,7 @@ diff --git a/client.js b/client.js "You have {version}.": "当前版本 {version}。", "The update did not finish: {reason}": "更新没有完成:{reason}", "What's new": "更新说明", -@@ -420,8 +660,70 @@ +@@ -420,8 +660,71 @@ "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -459,6 +459,7 @@ diff --git a/client.js b/client.js + "Complete task": "完成任务", + "Add task": "添加任务", + "Group task": "群任务", ++ "Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.": "派活、领活和进度都在这块看板上:一个总任务、每个部分一条子任务、负责人、列和进度。@ 只是叫醒对方。", + "Part of {title}": "属于 {title}", + "Task title": "任务标题", + Progress: "进度", @@ -5481,7 +5482,7 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +7060,1725 @@ +@@ -4731,49 +7060,1727 @@ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } @@ -6963,6 +6964,7 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-board-main" }, + error ? (0, import_react15.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, ++ lanes ? (0, import_react15.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, + scope?.kind === "bot" && onCreate ? (0, import_react15.createElement)( + "form", + { className: "bt-board-add", onSubmit: (event) => { @@ -7116,6 +7118,7 @@ diff --git a/client.js b/client.js +.bt-board-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-board-drop{margin:0;padding:18px 8px;border:1px dashed var(--bt-line-2);border-radius:12px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)} +.bt-board-empty{margin:0;padding:8px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-3)} ++.bt-board-hint{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +.bt-board-error{margin:0;font-size:13px;line-height:18px;color:#c21d2e} +.bt-board-lane{display:flex;flex-direction:column;gap:6px} +.bt-board-lane-name{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3);padding:0 2px} diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 9332c8b7e..f9314a506 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -761,7 +761,7 @@ diff --git a/src/client/sources.js b/src/client/sources.js diff --git a/src/host/groups.js b/src/host/groups.js --- a/src/host/groups.js +++ b/src/host/groups.js -@@ -7,21 +7,29 @@ +@@ -7,21 +7,30 @@ // the speaking order and the member's place in it. Nobody answers a room that has // moved on, and nobody has to count lines to know who went before. A plain message // goes to every member in roster order, admin first, or to the admin alone in admin @@ -794,10 +794,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js +import { faceMentions } from './face-mentions.js' +import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js' +import { createRoomParticipants } from './room-participant.js' ++import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, prepareRoomLine } from './room-context.js' const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 -@@ -31,6 +31,10 @@ const ROOM_EXTRA_TURNS = 4 +@@ -31,6 +31,10 @@ const ROOM_ADMIN_EXTRA_TURNS = 4 const ROOM_TURNS_EACH = 2 const ROOM_ADMIN_TURNS_EACH = 4 @@ -808,7 +809,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Logs written before rooms were versioned get sequence numbers in place. const ensureRoomLog = (room) => { -@@ -41,9 +41,64 @@ const ensureRoomLog = (room) => { +@@ -41,9 +41,64 @@ room.seen ??= {} } @@ -874,7 +875,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } // A member's own lines are already in its Session, so only the others' are news. -@@ -58,6 +58,9 @@ const lineText = (line) => { +@@ -58,6 +58,9 @@ if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]` if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]` if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]` @@ -884,19 +885,25 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -86,6 +86,11 @@ export const referenceNote = references => (references?.length +@@ -85,7 +85,17 @@ + ? `Conversations the user referenced in this round:\n\n${references.map(reference => reference.text).join('\n\n')}` : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' ++// A group that has a board. Assigning, claiming, and progress are board writes. ++// @Name only wakes. A face-to-face room has no board, so it keeps ROOM_REPLY. ++const ROOM_REPLY_BOARD = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Assigning work, claiming it, and progress are the task board: the overall task, the subtask, the assignee, the column, and the progress, written with track_tasks. @Name only wakes a member whose card already names them; it does not assign or record progress. Do not narrate CI, merge-queue, or review status in the group. When the board already shows it, reply [PASS]. A status meant for someone else is [PASS]. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' +// The turn's own closing line: the full reply rule above stands in the group's section of the system prompt. +const ROOM_REPLY_NOW = 'Reply now with your message for the group, no name prefix, or [PASS] to stay silent.' +// Words that address the whole room: a plain line with one of them may get an answer from each Bot (#299). +const ROOM_EVERYONE = /大家|你们|各位|所有人|所有 ?[Bb]ot|每个人|每位|各自|分别|everyone|everybody|y'?all|you all|all of you|each of you|introduce yourselves/i +const ROOM_REPLY_FORCED = 'This turn is the exception to the [PASS] rule: reply with your message for the group, no name prefix, short. Do not reply [PASS].' const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' ++// Same wake, on a room that has a board: the call does not assign or record progress. ++const ROOM_CALLS_BOARD = 'Writing "@Name" wakes that member to answer, or to do the card the board already assigned to it. It does not assign work or record progress. When it finishes that card, you answer its result. To only mention a member, write its name without "@".' const slotLabel = (slot) => { -@@ -100,52 +100,173 @@ const slotLabel = (slot) => { +@@ -100,165 +100,237 @@ return slot.name } @@ -1026,7 +1033,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js + const leadId = typeof rt.taskLeadOf === 'function' ? rt.taskLeadOf(room) : undefined + if (!leadId) return 'This room has no task lead on the board. Do not divide the work; the user or a Main Bot picks one task lead.' + if (leadId === member.id) { -+ return 'You are this room\'s task lead (总负责人) for the board. When the group is given a job, record one overall task with track_tasks and assign each part to the Bot it fits (assignee is that Bot\'s name). Nobody else plans. A card you assign is claimed: that Bot starts the work and does not wait for a confirmation. A question stays in this group.' ++ return 'You are this room\'s task lead (总负责人) for the board. When the group is given a job, assign it on the board with track_tasks: one overall task, and a subtask for each part (assignee is that Bot\'s name). Nobody else plans. A chat line does not assign. A card you assign is claimed: that Bot starts the work in its own chat and does not wait for a confirmation. Progress is that card\'s column and progress. A question a person has to decide is one short post in this group; do not @ a Bot to hand the work on.' } - await rt.applyPermission(member, id) - .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error)) @@ -1034,7 +1041,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js - await rt.ensureCapabilities(id) - return { id, brief: await rt.ownChatBrief(member) } + const name = String(rt.botOf(leadId)?.name ?? 'The task lead').replace(/[\r\n]+/g, ' ').slice(0, 80) -+ return `${name} is this room's task lead (总负责人) for the board: it writes the one plan and assigns the parts. Do the card it assigns you, or link your own subtask to that overall task. Do not record a new plan or assign work. Start the work; do not wait for a confirmation. A question about your card goes in your reply here, not in your own chat.` ++ return `${name} is this room's task lead (总负责人) for the board: it assigns the parts on the board. Claim the card it assigns you by setting its column to in-progress with track_tasks, and update that card's column and progress as the work moves. Do not record a new plan, assign work, or post a status in the group. @Name only wakes someone; it does not assign. Start the work in your own chat; do not wait for a confirmation. A question a person has to decide is one short post in this group; do not @ a Bot to hand the work on.` } - // Lines from before the round's opening message are kept apart from this round, so @@ -1050,7 +1057,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js const peers = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${rt.isAdminOf(room, bot.id) ? ' (admin)' : ''}`) const leads = rt.isAdminOf(room, member.id) -+ const members = `${isFaceRoom(room) ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}` ++ const face = isFaceRoom(room) ++ const members = `${face ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${face ? ROOM_CALLS : ROOM_CALLS_BOARD}` + return [ + '# This group chat', + 'This conversation is where you speak in one group chat. Each turn arrives as a "[Group chat …]" message with what is new and the speaking order; the rules below hold for every turn here.', @@ -1059,7 +1067,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js + leads ? adminNote(room) : faceMemberNote(room, member, rt.botOf), + taskBoardNote(room, member), + routeRules(room, member), -+ ROOM_REPLY, ++ face ? ROOM_REPLY : ROOM_REPLY_BOARD, + ].filter(Boolean).join('\n\n') + } + const roomSectionFor = (sessionId) => { @@ -1094,9 +1102,21 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // The group's standing rules are not repeated here: they are in the section above. + function roomPrompt(room, member, round) { const lines = unseenLines(room, member.id) - const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) - const current = lines.filter(line => line.seq >= round.startSeq) -@@ -153,14 +153,9 @@ export function install(rt) { +- const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) +- const current = lines.filter(line => line.seq >= round.startSeq) ++ const earlierLines = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) ++ const currentLines = lines.filter(line => line.seq >= round.startSeq) ++ // Count caps first, then a character budget. Long status essays collapse ++ // inside the budget; the stored log is unchanged (#1203). ++ const fitted = fitRoomLines([...earlierLines, ...currentLines], { ++ budget: ROOM_CONTEXT_BUDGET, ++ render: lineText, ++ prepare: prepareRoomLine, ++ }) ++ const earlier = fitted.entries.filter(entry => entry.line.seq < round.startSeq) ++ const current = fitted.entries.filter(entry => entry.line.seq >= round.startSeq) ++ const show = entries => entries.map(entry => entry.text).join('\n') + const spokeThisRound = (room.seen[member.id] ?? 0) >= round.startSeq const index = round.slots.findIndex(slot => slot.status === 'speaking') const before = index const after = round.slots.length - index - 1 @@ -1104,15 +1124,24 @@ diff --git a/src/host/groups.js b/src/host/groups.js - const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : '' - if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n') const working = workingIn(room).filter(item => item.bot.id !== member.id) ++ const board = isFaceRoom(room) ? '' : formatRoomBoard(rt.state.tasks, { ++ roomId: room.id, ++ nameOf: task => rt.botOf(task.ownerBotId)?.name, ++ }) return [ - members, - notice, - leads ? adminNote(room) : '', +- earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '', + noticeChange(room, member), - earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '', ++ board, ++ fitted.omitted > 0 ? `${fitted.omitted} older lines are not in this turn. Progress is on the task board; call read_group_chat when you need an exact earlier line.` : '', ++ earlier.length ? `Earlier in the group, before this round (context only):\n${show(earlier)}` : '', current.length - ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}` -@@ -169,90 +169,27 @@ export function install(rt) { +- ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}` ++ ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${show(current)}` + : 'Nothing new in this round since your last message.', + round.shown?.includes(member.id) ? '' : referenceNote(round.references), working.length > 0 ? `Still at work on what they were asked; each posts here when done: ${working.map(item => `${item.bot.name} (${minutesSince(item.since)} min)`).join(', ')}.` : '', `Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`, `You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`, @@ -1202,7 +1231,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // per-member turn bound for the round; `by` is the calling member, if a member called. - function callMembers(round, turns, index, callees, note, cap, by) { + // `uncapped` names the callees that bound does not apply to (#804). -+ function callMembers(round, turns, index, callees, note, cap, by, uncapped = NOBODY) { ++ function callMembers(round, turns, index, callees, note, cap, by, uncapped = NOBODY, cue) { let next = index + 1 for (const peer of callees) { - if ((turns.get(peer.id) ?? 0) >= cap) continue @@ -1210,7 +1239,15 @@ diff --git a/src/host/groups.js b/src/host/groups.js const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting') const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0] called.note = note -@@ -285,34 +285,138 @@ export function install(rt) { ++ if (typeof cue === 'string') called.cue = cue ++ else if (by === undefined) delete called.cue + if (by === undefined) delete called.by + else called.by = by + round.slots.splice(next, 0, called) + next += 1 + } + } +@@ -285,34 +285,138 @@ // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. function drainRoomInbox(room, round, turns, index, agent) { @@ -1332,7 +1369,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js + if (callees.length === 0) continue + const leads = rt.isAdminOf(room, call.by) + const uncapped = new Set(callees.filter(bot => rt.isAdminOf(room, bot.id)).map(bot => bot.id)) -+ callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped) ++ callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped, call.text) + } + return round.slots.length - before + } @@ -1357,7 +1394,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (index >= round.slots.length) break const slot = round.slots[index] const member = rt.botOf(slot.id) -@@ -321,6 +321,13 @@ export function install(rt) { +@@ -321,6 +321,20 @@ slot.status = 'left' continue } @@ -1367,11 +1404,18 @@ diff --git a/src/host/groups.js b/src/host/groups.js + slot.status = 'passed' + round.passed.push(member.name) + continue ++ } ++ // A Bot @ whose open card already shows this line is silence, not a turn (#1205). ++ // The slot is passed, so the report says stayed silent rather than no answer. ++ if (slot.by !== undefined && typeof slot.cue === 'string' && !isFaceRoom(room) && boardCoversCue(rt.state.tasks, { roomId: room.id, botId: member.id, text: slot.cue })) { ++ slot.status = 'passed' ++ round.passed.push(member.name) ++ continue + } slot.status = 'speaking' round.speaking = member.id round.turns += 1 -@@ -339,7 +339,13 @@ export function install(rt) { +@@ -339,7 +339,13 @@ const prompt = roomPrompt(room, member, round) round.shown?.push(member.id) const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}` @@ -1386,7 +1430,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js round.speaking = null if (reply?.failure !== undefined) { // Not a pass: the user sees why the member is silent, and the round goes on. -@@ -366,9 +366,18 @@ export function install(rt) { +@@ -366,9 +366,18 @@ if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() }) continue } @@ -1407,7 +1451,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js void rt.save().catch(() => {}) // Only the admin's "@all" calls everyone, so a member cannot set off a storm. const leads = rt.isAdminOf(room, member.id) -@@ -377,7 +377,11 @@ export function install(rt) { +@@ -377,7 +377,11 @@ const { all, found: named } = mentionsIn(reply, everyone) const found = named.filter(other => other.id !== member.id) const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found @@ -1416,11 +1460,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // and dropping that is the failure #804 was filed for. Everyone else keeps the cap, so a chain of + // Bots naming each other cannot keep a round talking. + const uncapped = new Set(found.filter(other => rt.isAdminOf(room, other.id)).map(other => other.id)) -+ if (!round.single) callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, member.id, uncapped) ++ if (!round.single) callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, member.id, uncapped, typeof reply === 'string' ? reply : undefined) } } -@@ -397,38 +397,45 @@ export function install(rt) { +@@ -397,38 +397,45 @@ rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 @@ -1477,7 +1521,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js id: member.id, name: member.name, status: 'waiting', - ...(opener.kickoff ? { note: 'the group was just created' } : {}), - ...(named.includes(member) ? { by: opener.botId } : {}), -+ ...(named.includes(member) ? (opener.botId === undefined ? { addressed: true } : { by: opener.botId }) : {}), ++ ...(named.includes(member) ? (opener.botId === undefined ? { addressed: true } : { by: opener.botId, cue: opener.text ?? '' }) : {}), })), + // One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop. + ...(isPlainFaceLine(room, opener) ? { single: true } : {}), @@ -1486,7 +1530,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js speaking: null, turns: 0, replies: [], -@@ -436,6 +436,7 @@ export function install(rt) { +@@ -436,6 +436,7 @@ failed: [], started: [], } @@ -1494,7 +1538,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js for (const post of posts.filter(item => item.failed)) { const bot = rt.botOf(post.botId) const { code, status, message } = post.failed -@@ -450,17 +450,25 @@ export function install(rt) { +@@ -450,17 +450,25 @@ appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) } @@ -1521,7 +1565,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } await rt.save() for (const post of posts) { -@@ -471,6 +471,41 @@ export function install(rt) { +@@ -471,6 +471,41 @@ }, })) @@ -1563,7 +1607,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Private context enters a group only through this tool, and the group sees each use. ctx.tools.register(defineTool({ name: 'read_own_chat', -@@ -539,7 +539,9 @@ export function install(rt) { +@@ -539,7 +539,9 @@ const { room, error } = rt.pickRoom(args.group, self) if (error) return error ensureRoomLog(room) @@ -1574,7 +1618,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -548,46 +548,207 @@ export function install(rt) { +@@ -548,46 +548,207 @@ // the admin answers alone in admin mode, or first of everyone. What a member posts // after its round moved on is answered by the member that asked for it; work the // user asked for is left for the user to read. @@ -1709,7 +1753,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // of the held post from calling them a second time. + const callees = mentionsIn(text, room.members.map(rt.botOf).filter(Boolean)).found.filter(bot => bot.id !== sender.id) + if (callees.length > 0) { -+ round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees }] ++ round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees, text }] + extra.called = true + } + return true @@ -1787,7 +1831,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error)) } rt.lateRoomReply = lateRoomReply -@@ -599,9 +599,8 @@ export function install(rt) { +@@ -599,9 +599,8 @@ const room = rt.roomOf(roomId) const member = rt.botOf(botId) const sessionId = room?.sessions?.[botId] @@ -1799,7 +1843,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js await postToRoom(room, member, '', { quiet: true, stopped: true }) return true } -@@ -615,37 +615,42 @@ export function install(rt) { +@@ -615,37 +615,42 @@ const member = rt.botOf(owner?.botId) if (room === undefined || member === undefined || !room.members.includes(member.id)) return const { code, status, message } = failure @@ -1858,7 +1902,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error)) } -@@ -665,7 +665,7 @@ export function install(rt) { +@@ -665,7 +665,7 @@ // message from the user, a scheduled task, or a Bot's post after the last model or // tool step starts a relay. const opens = message => message.role === 'user' diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index 55861e5c6..8083f3d50 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -289,6 +289,7 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, rail.groups.map(row => h(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect }))), h('div', { className: 'bt-board-main' }, error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null, + lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null, scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, @@ -401,6 +402,7 @@ export const KANBAN_CSS = ` .bt-board-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} .bt-board-drop{margin:0;padding:18px 8px;border:1px dashed var(--bt-line-2);border-radius:12px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)} .bt-board-empty{margin:0;padding:8px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-3)} +.bt-board-hint{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} .bt-board-error{margin:0;font-size:13px;line-height:18px;color:#c21d2e} .bt-board-lane{display:flex;flex-direction:column;gap:6px} .bt-board-lane-name{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3);padding:0 2px} diff --git a/patches/ds-bot/files/src/host/room-context.js b/patches/ds-bot/files/src/host/room-context.js new file mode 100644 index 000000000..74b3b2f85 --- /dev/null +++ b/patches/ds-bot/files/src/host/room-context.js @@ -0,0 +1,167 @@ +// What a group turn shows the model of the room (#1203). The log on disk stays +// whole. The turn gets a newer-first slice under a character budget, and a long +// status essay is collapsed so it cannot fill that slice. Progress for the open +// job is a compact board snapshot in the same turn, not those essays. +import { compareForWork, progressOf } from '../shared/task-board.js' + +// Characters, not a tokenizer. The essays this was measured on are mostly CJK, +// about one token per character, so a character ceiling is the conservative one: +// English fits fewer tokens than this, never more. +export const ROOM_CONTEXT_BUDGET = 4000 + +// A bot line shorter than this is a handoff or a question, not an essay. +const STATUS_MIN = 400 + +const TERMINAL = new Set(['accepted', 'cancelled', 'failed']) + +// Two of these in a long bot line is CI / merge / review narration. A work +// assignment that only cites an issue number stays whole. +const STATUS_HINTS = [ + /merge[_ -]?group/i, + /tests-ok/i, + /\benqueue/i, + /\bmergeable\b/i, + /\bCI\b/, + /\bPASS\b/, + /Verdict/i, + /全绿/, + /入队/, + /合入/, + /队列/, + /\b[0-9a-f]{7,40}\b/, +] + +const oneLine = (value, max) => { + let flat = '' + for (const char of String(value ?? '')) { + const code = char.codePointAt(0) + flat += code < 0x20 || code === 0x7f || char === '<' || char === '>' ? ' ' : char + } + flat = flat.replace(/\s+/g, ' ').trim() + if (flat.length <= max) return flat + return `${flat.slice(0, Math.max(0, max - 1))}…` +} + +export function statusShaped(text) { + const body = String(text ?? '') + if (body.length < STATUS_MIN) return false + let hits = 0 + for (const hint of STATUS_HINTS) if (hint.test(body)) hits += 1 + return hits >= 2 +} + +const DRIVING_STATUS = new Set(['open', 'claimed', 'working', 'blocked', 'result-ready']) + +// A bare acknowledgement. Anything that asks for work stays a turn. +const CUE_ACK = /^(done|ok|okay|noted|shipped|passed|继续|好了|完成|已完成|搞定|收到)[.!。!]?$/i + +const foldCue = (value) => String(value ?? '') + .replace(/@[^\s@,,。]{1,40}/g, ' ') + .replace(/\p{P}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase() + +// A Bot @ is already on the board when this member has an open card in the room +// and the whole line, with @names and punctuation folded out, is already in that +// card (#1205). A title that only begins a new instruction is not covered. +// A question is never covered: that line is the one short ask a person may need. +export function boardCoversCue(tasks, { roomId, botId, text } = {}) { + if (!roomId || !botId) return false + const cards = Object.values(tasks ?? {}).filter(task => + task?.origin?.roomId === roomId + && task.ownerBotId === botId + && task.kind !== 'group' + && task.waitingFor !== 'input' + && DRIVING_STATUS.has(task.status)) + if (cards.length === 0) return false + const line = String(text ?? '') + if (/[??]/.test(line)) return false + if (statusShaped(line)) return true + const body = foldCue(line) + if (body.length === 0 || CUE_ACK.test(body)) return true + const hay = foldCue(cards.map(task => `${task.title ?? ''} ${task.note ?? ''}`).join('\n')) + return body.length >= 8 && hay.includes(body) +} + +// The first hundred characters, plus up to four @names, so a call is still visible +// after the essay is gone. The character count is the length before the cut. +export function compactStatus(text) { + const flat = oneLine(text, 100000) + const mentions = [...new Set([...flat.matchAll(/@[^\s@,,。]{1,40}/g)].map(match => match[0]))].slice(0, 4) + const head = flat.slice(0, 100).trim() + const more = flat.length > 100 ? '…' : '' + const called = mentions.length > 0 ? ` ${mentions.join(' ')}` : '' + return `[status on the board, ${flat.length} chars] ${head}${more}${called}` +} + +// Bot speech only. A person's line, and a short line, stay as stored. The log +// object is not modified: a collapsed line is a copy. +export function prepareRoomLine(line) { + if (line == null || typeof line !== 'object') return line + if (line.kind || line.botId === undefined || typeof line.text !== 'string') return line + if (!statusShaped(line.text)) return line + return { ...line, text: compactStatus(line.text) } +} + +// Newest line first until `budget` characters of rendered text. A line that does +// not fit is skipped, so a short older line can still land when a long one +// between them cannot. One line longer than the budget is clipped to it. +export function fitRoomLines(lines, { budget = ROOM_CONTEXT_BUDGET, render, prepare = line => line } = {}) { + const source = Array.isArray(lines) ? lines : [] + const kept = [] + let used = 0 + for (let index = source.length - 1; index >= 0; index -= 1) { + const line = prepare(source[index]) + let text = String(render(line)) + const gap = kept.length > 0 ? 1 : 0 + if (kept.length > 0 && used + gap + text.length > budget) continue + if (used + gap + text.length > budget) { + const room = budget - used - gap + if (room < 48) break + text = `${text.slice(0, room - 1)}…` + kept.push({ line, text }) + break + } + used += gap + text.length + kept.push({ line, text }) + } + kept.reverse() + return { + entries: kept, + text: kept.map(item => item.text).join('\n'), + omitted: source.length - kept.length, + } +} + +function cardLine(task, nameOf) { + const who = oneLine(typeof nameOf === 'function' ? nameOf(task) : '', 40) || 'unassigned' + const title = oneLine(task.title || '(untitled)', 80) + const status = oneLine(task.status || 'open', 24) + const pct = typeof task.progress === 'number' && Number.isFinite(task.progress) ? ` ${progressOf(task)}%` : '' + const wait = task.waitingFor === 'input' ? ' waiting' : '' + const note = task.note ? ` — ${oneLine(task.note, 80)}` : '' + return `${who} [${status}${pct}${wait}] ${title}${note}` +} + +// Open cards for one room: the newest overall task, then its subtasks and any +// room card that is not under a different overall task. Done cards stay off. +export function formatRoomBoard(tasks, { roomId, nameOf, limit = 8 } = {}) { + if (!roomId) return '' + const open = Object.values(tasks ?? {}).filter(task => task && task.origin?.roomId === roomId && !TERMINAL.has(task.status)) + if (open.length === 0) return '' + const overall = open.filter(task => task.kind === 'group').sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0] + const cards = [] + if (overall) cards.push({ task: overall, indent: '' }) + for (const task of open.filter(item => item !== overall && item.kind !== 'group').sort(compareForWork)) { + if (cards.length >= limit) break + if (overall && task.parentId && task.parentId !== overall.id) continue + cards.push({ task, indent: overall && task.parentId === overall.id ? ' ' : '' }) + } + if (cards.length === 0) return '' + const lines = ['Task board (assign, claim, and progress live here; update it with track_tasks, do not restate it in chat):'] + for (const { task, indent } of cards) lines.push(`${indent}- ${cardLine(task, nameOf)}`) + const hidden = open.length - cards.length + if (hidden > 0) lines.push(`- ${hidden} more open card(s) not shown`) + return lines.join('\n') +} diff --git a/patches/ds-bot/files/src/host/task-drive.js b/patches/ds-bot/files/src/host/task-drive.js index 905aa592e..bc244e67b 100644 --- a/patches/ds-bot/files/src/host/task-drive.js +++ b/patches/ds-bot/files/src/host/task-drive.js @@ -1,10 +1,11 @@ // Unfinished conversation tasks wake the Bot that owns them (#1109). The board // stays the one in tasks.js: this module only decides when an idle Session should -// continue the task track_tasks would name next. An own-chat task is a follow-up -// in that chat. A group subtask is a turn in that Bot's group Session (#1182): -// the reply is posted into the room when the turn ends, and the driver does not -// open a round of its own. +// continue the task track_tasks would name next. Every wake, including a group +// subtask, is a follow-up in that Bot's own chat (#1205). The driver does not +// open a group Session and does not post the reply into the room. Assigning, +// progress, and the next assignee are board writes; chat is not the scheduler. import { compareForWork } from '../shared/task-board.js' +import { formatRoomBoard } from './room-context.js' // A no-op track_tasks call still bumps updatedAt. The hash leaves that stamp out, // so a wake that did not move the board waits before the next one. @@ -35,17 +36,19 @@ const cueFor = (task) => { const post = task.writeBack?.state === 'pending' ? ` Then post the decision comment on ${task.writeBack.target} with your GitHub connector and record it with track_tasks postResult {task: '${task.id}', ok: true}, or ok: false with the error.` : '' - const lead = `Continue ${task.id} [${priority}] ${title}. Do this task now. Update it with track_tasks as it moves. Stop when it is done or waiting on an answer. Do not invent other work` + const head = `Continue ${task.id} [${priority}] ${title}. Do this task now.` + const stop = 'Stop when it is done or waiting on an answer. Do not invent other work' if (task.origin?.roomId) { - return `${lead} or record a new plan. This card is already yours: start the work and do not wait for a confirmation. If you need an answer, ask that one question in your reply here, never in your own chat.${post}` + return `${head} Taking it and moving it are board writes: set this card's column and progress with track_tasks, and do not post a status in the group. ${stop} or record a new plan. This card is already yours: set its column to in-progress and start the work, and do not wait for a confirmation. Update the note on the card as it moves. The group stays quiet. If a person has to decide, post one short question in the group; do not @ a Bot to hand the work on. Completing or updating this card wakes the next assignee.${post}` } - return `${lead} or start a group round.${post}` + return `${head} Set this card's column and progress with track_tasks as it moves. ${stop} or start a group round.${post}` } const messageText = (message) => (Array.isArray(message?.content) ? message.content : []).map(block => block?.text ?? '').join('\n') -// An own-chat wake is role `task`. A group wake is a room turn (`taskDrive`), so -// the same inbox check has to see it or the next board write queues a second one. +// A wake is role `task` in the Bot's own chat, including a group card (#1205). +// A room turn left from an older driver (`taskDrive`) still counts, so a board +// write does not queue a second wake beside one that has not started. const taskMessages = (agent) => [...(agent?.inbox?.nextTurn ?? []), ...(agent?.inbox?.nextStep ?? [])] .filter(message => message?.source?.role === 'task' || message?.source?.taskDrive === true) @@ -86,34 +89,22 @@ export function install(rt) { return false } + // The wake always lands in the owner's own chat. A group card still has to + // name a member of that room; a face-to-face room has no board. const ownsSession = (task, sessionId) => { - const roomId = task.origin?.roomId - if (!roomId) return rt.chatOf(task.ownerBotId) === sessionId - return rt.roomOf(roomId)?.sessions?.[task.ownerBotId] === sessionId + const chat = rt.chatOf(task.ownerBotId) + return chat === sessionId && !rt.roomOf(sessionId) } - // The group Session when this Bot already has one there, or a new one when the - // subtask is the first thing it does in that group. A live round waits: the - // driver must not add a member turn beside the round. - const sessionFor = async (task) => { + const sessionFor = (task) => { const roomId = task.origin?.roomId - if (!roomId) { - const id = rt.chatOf(task.ownerBotId) - return id && !rt.roomOf(id) ? id : undefined - } - const room = rt.roomOf(roomId) - if (!room || room.origin === 'face-to-face') return undefined - if (!(room.members ?? []).includes(task.ownerBotId)) return undefined - if (roundLive(room.id)) return undefined - const existing = room.sessions?.[task.ownerBotId] - if (existing) return existing - if (typeof rt.memberSession !== 'function') return undefined - try { - return await rt.memberSession(room, task.ownerBotId) - } catch (error) { - rt.warn('ds-bot: task drive could not open a group session for %s: %s', task.ownerBotId, error) - return undefined + if (roomId) { + const room = rt.roomOf(roomId) + if (!room || room.origin === 'face-to-face') return undefined + if (!(room.members ?? []).includes(task.ownerBotId)) return undefined } + const id = rt.chatOf(task.ownerBotId) + return id && !rt.roomOf(id) ? id : undefined } // `live` is cleared before the turn-end hook runs. The Agent's status can still @@ -194,10 +185,10 @@ export function install(rt) { if (!list.includes(task)) continue byBot.set(task.ownerBotId, list) } - // One pass wakes every owner. A group assignment starts each member's own - // subtask in that same drive, in that member's Session. One Bot still gets - // only the task compareForWork names next, and a Session that already has - // a turn is left until that turn ends. + // One pass wakes every owner, in that owner's own chat. A group assignment + // starts each assignee there in the same drive and does not open a round. + // One Bot still gets only the task compareForWork names next, and a Session + // that already has a turn is left until that turn ends. for (const [botId, owned] of byBot) { const list = owned.slice().sort(compareForWork) const next = list[0] @@ -230,15 +221,17 @@ export function install(rt) { inflight.set(sessionId, { fingerprint: fp, started: false }) const roomId = next.origin?.roomId try { - // A group card is a room turn: the reply posts into the group when the - // turn ends (turns.js, no waiter), so a question stays there (#1182). - // noteWake counts only a wake that left, including this room turn (#1172). + // Own chat, even for a group card (#1205). The reply is not a room turn, + // so it is not posted into the group when the turn ends. noteWake counts + // only a wake that left (#1172). The board snapshot is the progress. + const board = roomId + ? formatRoomBoard(rt.state.tasks, { roomId, nameOf: task => rt.botOf(task.ownerBotId)?.name }) + : '' await rt.deliver({ toId: botId, sessionId, - role: roomId ? 'room' : 'task', - text: cueFor(next), - ...(roomId ? { roomId, note: 'your task', taskDrive: true } : {}), + role: 'task', + text: board ? `${cueFor(next)}\n\n${board}` : cueFor(next), }) await noteWake(next) } catch (error) { diff --git a/patches/ds-bot/files/test/bot-tasks.test.js b/patches/ds-bot/files/test/bot-tasks.test.js index 43b5cd62f..ff49a66c6 100644 --- a/patches/ds-bot/files/test/bot-tasks.test.js +++ b/patches/ds-bot/files/test/bot-tasks.test.js @@ -295,7 +295,7 @@ test('a group keeps one overall task and a subtask per Bot, and unfinished work assert.match(chief, /Next: \S+ \[in-progress high\] Going high/) const asked = await app.track('s-war', { tasks: [{ id: draft.id, ask: 'Which tone?' }] }) - assert.match(asked, /ask the group in your reply: Which tone\?/) + assert.match(asked, /Post one short question in the group only when a person there has to decide: Which tone\?/) const waiting = (await list(app.call, alice.sessionToken, { botId: 's' })).find(task => task.id === draft.id) assert.equal(waiting.status, 'blocked') assert.equal(waiting.waitingFor, 'input') diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index 9f88cfc06..5dccdd781 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -131,6 +131,8 @@ test('the board draws the three columns, nests the child, and moves only the own }) assert.match(textOf(group), /Theirs/) assert.match(textOf(group), /Other/) + assert.match(textOf(group), /Assign, claim, and progress live on this board/) + assert.doesNotMatch(textOf(tree), /Assign, claim, and progress live on this board/) assert.equal(all(group).some(node => node.type === 'button' && (textOf(node) === 'Start' || textOf(node) === 'Complete')), false) assert.equal(byClass(group, 'bt-board-card')[0].props.draggable, undefined) }) diff --git a/patches/ds-bot/files/test/room-context.test.js b/patches/ds-bot/files/test/room-context.test.js new file mode 100644 index 000000000..60c5cc199 --- /dev/null +++ b/patches/ds-bot/files/test/room-context.test.js @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ROOM_CONTEXT_BUDGET, boardCoversCue, compactStatus, fitRoomLines, formatRoomBoard, prepareRoomLine, statusShaped } from '../src/host/room-context.js' + +const render = line => `${line.who}: ${line.text}` +const essay = (tail) => `CI on tip abcdef12 is green. tests-ok passed. enqueue when mergeable. merge_group is next. PASS at abcdef12. @Chief ${'detail '.repeat(80)}${tail}` + +test('a long CI essay is status-shaped and a work note is not', () => { + assert.equal(statusShaped(essay('end')), true) + assert.equal(statusShaped('CI enqueue'), false, 'a short line is a handoff') + const work = `Please change docs/autonomous-dev.md so the four spots say the mechanism is not enabled. ${'word '.repeat(80)}` + assert.equal(statusShaped(work), false) +}) + +test('collapsing a status line keeps the opening and the @name, not the essay', () => { + const text = essay('ESSAY_BODY_MARKER') + assert.ok(text.indexOf('ESSAY_BODY_MARKER') > 200) + const compact = compactStatus(text) + assert.match(compact, /^\[status on the board, \d+ chars\] /) + assert.match(compact, /@Chief/) + assert.doesNotMatch(compact, /ESSAY_BODY_MARKER/) + assert.ok(compact.length < 220) +}) + +test('prepare collapses bot status and leaves the stored line and a person line', () => { + const stored = { seq: 1, who: 'Dev', botId: 'dev', text: essay('TAIL') } + const next = prepareRoomLine(stored) + assert.equal(stored.text.includes('TAIL'), true, 'the log line is not rewritten') + assert.notEqual(next, stored) + assert.match(next.text, /\[status on the board/) + assert.doesNotMatch(next.text, /TAIL/) + const person = { seq: 2, who: 'User', text: essay('PERSON') } + assert.equal(prepareRoomLine(person), person) + const short = { seq: 3, who: 'Dev', botId: 'dev', text: 'short handoff keep me' } + assert.equal(prepareRoomLine(short), short) +}) + +test('the transcript stays inside the budget and prefers a newer short line over an older essay', () => { + const lines = [] + for (let seq = 1; seq <= 8; seq += 1) lines.push({ seq, who: 'Dev', botId: 'dev', text: essay(`marker-${seq}`) }) + lines.push({ seq: 9, who: 'Dev', botId: 'dev', text: 'short handoff keep me' }) + const fitted = fitRoomLines(lines, { budget: ROOM_CONTEXT_BUDGET, render, prepare: prepareRoomLine }) + assert.ok(fitted.text.length <= ROOM_CONTEXT_BUDGET) + assert.match(fitted.text, /short handoff keep me/) + assert.match(fitted.text, /\[status on the board/) + assert.doesNotMatch(fitted.text, /marker-/) + assert.equal(fitted.entries.at(-1).line.seq, 9) +}) + +test('a non-status flood is cut newest-first instead of being collapsed', () => { + const lines = [] + for (let seq = 1; seq <= 10; seq += 1) lines.push({ seq, who: 'Dev', botId: 'dev', text: `work item ${seq} ${'word '.repeat(160)}` }) + const fitted = fitRoomLines(lines, { budget: 1000, render, prepare: prepareRoomLine }) + assert.ok(fitted.text.length <= 1000) + assert.match(fitted.text, /work item 10 /) + assert.doesNotMatch(fitted.text, /work item 1 /) + assert.ok(fitted.omitted > 0) + assert.equal(fitted.entries.at(-1).text.includes('word '), true, 'a work line is not rewritten as status') +}) + +test('one line longer than the budget is clipped to it', () => { + const lines = [{ seq: 1, who: 'User', text: 'x'.repeat(ROOM_CONTEXT_BUDGET + 500) }] + const fitted = fitRoomLines(lines, { budget: ROOM_CONTEXT_BUDGET, render, prepare: prepareRoomLine }) + assert.ok(fitted.text.length <= ROOM_CONTEXT_BUDGET) + assert.equal(fitted.omitted, 0) + assert.ok(fitted.text.endsWith('…')) +}) + +test('the board snapshot is the open overall task and its subtasks', () => { + const tasks = { + overall: { id: 'overall', kind: 'group', status: 'open', title: 'Ship note', progress: 40, note: 'line one\nline two', ownerBotId: 'chief', origin: { roomId: 'room' }, updatedAt: 2 }, + child: { id: 'child', status: 'claimed', title: 'Writer implements', parentId: 'overall', ownerBotId: 'writer', origin: { roomId: 'room' }, updatedAt: 3, waitingFor: 'input' }, + done: { id: 'done', status: 'accepted', title: 'FINISHED_MARKER', ownerBotId: 'writer', origin: { roomId: 'room' }, updatedAt: 4 }, + other: { id: 'other', status: 'open', title: 'OTHER_ROOM', ownerBotId: 'writer', origin: { roomId: 'elsewhere' }, updatedAt: 5 }, + stray: { id: 'stray', kind: 'group', status: 'open', title: 'Older plan', ownerBotId: 'chief', origin: { roomId: 'room' }, updatedAt: 1 }, + } + const names = { chief: 'Chief', writer: 'Writer' } + const board = formatRoomBoard(tasks, { roomId: 'room', nameOf: task => names[task.ownerBotId] }) + assert.match(board, /Task board \(assign, claim, and progress live here/) + assert.match(board, /Chief \[open 40%\] Ship the note — line one line two/) + assert.match(board, /Writer \[claimed waiting\] Writer implements/) + assert.doesNotMatch(board, /FINISHED_MARKER|OTHER_ROOM|Older plan|<|>/) + assert.equal(formatRoomBoard({}, { roomId: 'room' }), '') + assert.equal(formatRoomBoard(tasks, { roomId: 'missing' }), '') +}) + +test('an open card covers a status @ and not a question or a new ask', () => { + const tasks = { + child: { id: 'child', status: 'working', title: 'Writer implements', note: 'draft is in', progress: 40, ownerBotId: 'writer', origin: { roomId: 'room' } }, + waiting: { id: 'waiting', status: 'blocked', title: 'Need a name', waitingFor: 'input', ownerBotId: 'writer', origin: { roomId: 'room' } }, + } + const cue = { roomId: 'room', botId: 'writer' } + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer Writer implements' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer Writer implements — draft is in' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer draft is in' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer Writer implements the rollback instead' }), false) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer Writer implements, then delete the notes file' }), false) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer done' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer' }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: essay('@Writer') }), true) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer which file?' }), false) + assert.equal(boardCoversCue(tasks, { ...cue, text: '@Writer the draft is wrong, redo paragraph two' }), false) + assert.equal(boardCoversCue(tasks, { roomId: 'room', botId: 'other', text: '@Other Writer implements' }), false) + assert.equal(boardCoversCue({ only: { ...tasks.waiting } }, { ...cue, text: '@Writer done' }), false) +}) diff --git a/patches/ds-bot/files/test/task-drive.test.js b/patches/ds-bot/files/test/task-drive.test.js index d03c96a33..8b4bf966e 100644 --- a/patches/ds-bot/files/test/task-drive.test.js +++ b/patches/ds-bot/files/test/task-drive.test.js @@ -1,8 +1,8 @@ // Unfinished conversation tasks wake the owning Bot (#1109). A wake is a follow-up -// in that Bot's own chat. A group subtask is a room turn in that Bot's group -// Session (#1182); the reply is posted into the room when the turn ends. A task -// waiting on an answer, a turn already open, and a wake that did not move the -// board do not start another one. An overall group card is the plan, not a turn. +// in that Bot's own chat, including a group subtask (#1205): the driver does not +// open a group Session and does not post the reply into the room. A task waiting +// on an answer, a turn already open, and a wake that did not move the board do +// not start another one. An overall group card is the plan, not a turn. import assert from 'node:assert/strict' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -235,18 +235,22 @@ test('finishing the named task before its wake starts continues the next one', a assert.match(agent.inbox.nextTurn[0].content.map(block => block.text).join('\n'), new RegExp(low)) }) -test('a group subtask wakes that group session and not the room or another Bot', async (t) => { +test('a group subtask wakes the assignee own chat and not the room', async (t) => { const app = await start(t) const body = await app.track('a', { tasks: [ { title: 'Ship the note', overall: true, group: 'War', priority: 'urgent' }, { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'high' }, ] }) - app.idOf(body, 'Write the draft') - assert.match(app.text('s-war'), /\[Group chat "War" · your task\]/) - assert.equal(app.calls('s-war')[0].source.role, 'room') - assert.equal(app.calls('s-war')[0].source.taskDrive, true) - assert.match(app.text('s-war'), /Write the draft/) - assert.equal(app.calls('s').length, 0, 'the subtask runs in the group session') + const id = app.idOf(body, 'Write the draft') + assert.equal(app.calls('s').length, 1) + assert.equal(app.calls('s')[0].source.role, 'task') + assert.equal(app.calls('s')[0].source.taskDrive, undefined) + assert.equal(app.calls('s')[0].source.roomId, undefined) + assert.match(app.text('s'), /\[Continue task\]/) + assert.match(app.text('s'), new RegExp(`Continue ${id} \\[high\\] Write the draft`)) + assert.match(app.text('s'), /do not post a status in the group/) + assert.match(app.text('s'), /Task board \(assign, claim, and progress live here/) + assert.equal(app.calls('s-war').length, 0, 'the subtask does not open a group turn') assert.equal(app.calls('a').length, 0, 'the overall card does not wake its owner') assert.equal(app.calls('war').length, 0, 'the driver does not post into the room') assert.equal(app.calls('b').length, 0) @@ -262,19 +266,17 @@ test('an overall task on its own does not wake anyone', async (t) => { assert.equal(app.calls('war').length, 0) }) -test('a subtask opens the member session when the Bot has not spoken in the group', async (t) => { +test('a subtask wakes the own chat and does not open a group session', async (t) => { const app = await start(t) await app.track('a', { tasks: [ { title: 'Ship the note', overall: true, group: 'War' }, { title: 'Bob writes', parent: 'Ship the note', assignee: 'BobBot', group: 'War', priority: 'urgent' }, ] }) - const sessionId = app.rt.state.rooms.war.sessions.b - assert.equal(typeof sessionId, 'string') - assert.notEqual(sessionId, 'b') - assert.match(app.text(sessionId), /\[Group chat "War" · your task\]/) - assert.equal(app.calls(sessionId)[0].source.role, 'room') - assert.match(app.text(sessionId), /Bob writes/) - assert.equal(app.calls('b').length, 0) + assert.equal(app.rt.state.rooms.war.sessions.b, undefined) + assert.equal(app.calls('b').length, 1) + assert.equal(app.calls('b')[0].source.role, 'task') + assert.match(app.text('b'), /\[Continue task\]/) + assert.match(app.text('b'), /Bob writes/) assert.equal(app.calls('war').length, 0) }) @@ -289,39 +291,37 @@ test('group members advance their own subtasks in the same drive', async (t) => const write = app.idOf(body, 'Write the draft') const check = app.idOf(body, 'Check the facts') const bob = app.idOf(body, 'Bob writes') - const bobSession = app.rt.state.rooms.war.sessions.b - assert.equal(typeof bobSession, 'string') - assert.equal(app.calls('s-war').length, 1) - assert.match(app.text('s-war'), new RegExp(`Continue ${write} \\[high\\] Write the draft`)) - assert.doesNotMatch(app.text('s-war'), new RegExp(check)) - assert.equal(app.calls(bobSession).length, 1) - assert.match(app.text(bobSession), new RegExp(`Continue ${bob} \\[urgent\\] Bob writes`)) - assert.equal(app.calls('s').length, 0) - assert.equal(app.calls('b').length, 0) + assert.equal(app.calls('s').length, 1) + assert.match(app.text('s'), new RegExp(`Continue ${write} \\[high\\] Write the draft`)) + assert.doesNotMatch(app.text('s'), new RegExp(check)) + assert.equal(app.calls('b').length, 1) + assert.match(app.text('b'), new RegExp(`Continue ${bob} \\[urgent\\] Bob writes`)) + assert.equal(app.calls('s-war').length, 0) assert.equal(app.calls('a').length, 0) assert.equal(app.calls('war').length, 0) await app.ctx.taskDrive.consider() - assert.equal(app.calls('s-war').length, 1, 'a queued wake is not duplicated') - assert.equal(app.calls(bobSession).length, 1) + assert.equal(app.calls('s').length, 1, 'a queued wake is not duplicated') + assert.equal(app.calls('b').length, 1) }) test('one member already in a turn does not hold the other member back', async (t) => { const app = await start(t) - app.emit('s-war', 'turn/start', { turn: 1 }) + app.emit('s', 'turn/start', { turn: 1 }) const body = await app.track('a', { tasks: [ { title: 'Ship the note', overall: true, group: 'War' }, { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'high' }, { title: 'Bob writes', parent: 'Ship the note', assignee: 'BobBot', group: 'War', priority: 'urgent' }, ] }) const write = app.idOf(body, 'Write the draft') - const bobSession = app.rt.state.rooms.war.sessions.b - assert.equal(app.calls('s-war').length, 0, 'Writer is mid-turn, so that Session is not woken again') - assert.equal(app.calls(bobSession).length, 1) - app.emit('s-war', 'turn/end', { reason: { kind: 'completed' } }) + assert.equal(app.calls('s').length, 0, 'Writer is mid-turn, so that chat is not woken again') + assert.equal(app.calls('b').length, 1) + assert.equal(app.calls('war').length, 0) + app.emit('s', 'turn/end', { reason: { kind: 'completed' } }) await app.ctx.taskDrive.settled() - assert.equal(app.calls('s-war').length, 1) - assert.match(app.text('s-war'), new RegExp(`Continue ${write} \\[high\\] Write the draft`)) - assert.equal(app.calls(bobSession).length, 1) + assert.equal(app.calls('s').length, 1) + assert.match(app.text('s'), new RegExp(`Continue ${write} \\[high\\] Write the draft`)) + assert.equal(app.calls('b').length, 1) + assert.equal(app.calls('war').length, 0) }) test('a member waiting on an answer stays quiet while another member is woken', async (t) => { @@ -331,35 +331,34 @@ test('a member waiting on an answer stays quiet while another member is woken', { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'high' }, ] }) const write = app.idOf(planned, 'Write the draft') - assert.equal(app.calls('s-war').length, 1) - app.beginWake('s-war') - const asked = await app.track('s-war', { tasks: [{ id: write, ask: 'Which tone?' }] }) + assert.equal(app.calls('s').length, 1) + app.beginWake('s') + const asked = await app.track('s', { tasks: [{ id: write, ask: 'Which tone?' }] }) assert.match(asked, /Which tone\?/) - app.emit('s-war', 'turn/end', { reason: { kind: 'completed' } }) + app.emit('s', 'turn/end', { reason: { kind: 'completed' } }) await app.ctx.taskDrive.settled() const body = await app.track('a', { tasks: [ { title: 'Bob writes', parent: 'Ship the note', assignee: 'BobBot', group: 'War', priority: 'urgent' }, ] }) app.idOf(body, 'Bob writes') - const bobSession = app.rt.state.rooms.war.sessions.b - assert.equal(app.calls('s-war').length, 1, 'a task waiting on an answer does not wake again') - assert.equal(app.calls(bobSession).length, 1) + assert.equal(app.calls('s').length, 1, 'a task waiting on an answer does not wake again') + assert.equal(app.calls('b').length, 1) + assert.equal(app.calls('war').length, 0) await app.ctx.taskDrive.consider() - assert.equal(app.calls('s-war').length, 1) - assert.equal(app.calls(bobSession).length, 1) + assert.equal(app.calls('s').length, 1) + assert.equal(app.calls('b').length, 1) }) -test('a live group round defers the member wake until the round is over', async (t) => { +test('a live group round does not turn the wake into a group turn', async (t) => { const app = await start(t) app.rt.roomProgress.set('round-1', { roomId: 'war' }) await app.track('a', { tasks: [ { title: 'Ship the note', overall: true, group: 'War' }, { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'urgent' }, ] }) + assert.equal(app.calls('s').length, 1) + assert.equal(app.calls('s')[0].source.role, 'task') assert.equal(app.calls('s-war').length, 0) - app.rt.roomProgress.delete('round-1') - await app.ctx.taskDrive.consider() - assert.equal(app.calls('s-war').length, 1) assert.equal(app.calls('war').length, 0) }) @@ -370,11 +369,9 @@ test('a board card that is not a conversation task does not wake', async (t) => assert.equal(app.calls('s').length, 0) }) -// #1182: one planner, the assigned card is claimed, and the wake is a group turn -// whose reply is posted into the room. Red on 2521aab: a second Main records the -// overall task, the subtask stays open, and the wake is role `task` so the reply -// never reaches the room. A member's own room card stays allowed. -test('only the task lead assigns, the card is claimed, and the wake is a group turn (#1182)', async (t) => { +// #1182: one planner, and the assigned card is claimed. #1205: the wake is the +// assignee's own chat. The reply stays there. A member's own room card stays allowed. +test('only the task lead assigns, the card is claimed, and the assignee is woken in its own chat (#1205)', async (t) => { const app = await start(t) await assert.rejects(app.rt.updateRoom('war', { taskLead: 'b' }, 's'), /Only the user, a Main Bot, or the admin/) app.rt.state.mainBotIds = ['a', 'b'] @@ -401,34 +398,75 @@ test('only the task lead assigns, the card is claimed, and the wake is a group t assert.match(planned, /overall/) const child = Object.values(app.rt.state.tasks).find(task => task.title === 'Bob writes') assert.equal(child.status, 'claimed') - const sessionId = room.sessions.b - assert.equal(typeof sessionId, 'string') - const wake = app.calls(sessionId).at(-1) - assert.equal(wake.source.role, 'room') - assert.equal(wake.source.taskDrive, true) - assert.equal(wake.source.roomId, 'war') + assert.equal(room.sessions.b, undefined, 'assigning does not open a group session') + const wake = app.calls('b').at(-1) + assert.equal(wake.source.role, 'task') + assert.equal(wake.source.taskDrive, undefined) + assert.equal(wake.source.roomId, undefined) const text = wake.content.map(block => block.text).join('\n') - assert.match(text, /\[Group chat "War" · your task\]/) + assert.match(text, /\[Continue task\]/) assert.match(text, new RegExp(`Continue ${child.id} \\[urgent\\] Bob writes`)) assert.match(text, /do not wait for a confirmation/) - assert.match(text, /never in your own chat/) + assert.match(text, /do not @ a Bot to hand the work on/) + assert.doesNotMatch(text, /never in your own chat/) + assert.match(text, /set this card's column and progress with track_tasks/) + assert.match(text, /do not post a status in the group/) + assert.doesNotMatch(text, /Update it with track_tasks as it moves/) + assert.match(text, /Task board \(assign, claim, and progress live here/) + assert.match(text, /Ship the note/) + assert.match(text, /Bob writes/) assert.equal(app.calls('war').length, 0, 'the driver does not post the cue into the room') - assert.equal(app.calls('b').length, 0, 'the subtask does not wake the own chat') + assert.equal(app.calls('b').length, 1) const linked = await app.track('b', { tasks: [{ title: 'Bob extra', parent: 'Ship the note', group: 'War' }] }) assert.match(linked, /Bob extra/) - assert.equal(app.calls(sessionId).length, 1, 'linking a subtask does not queue a second wake') + assert.equal(app.calls('b').length, 1, 'linking a subtask does not queue a second wake') + + app.beginWake('b') + app.emit('b', 'assistant/message', { message: { content: [{ type: 'text', text: 'Which file should the note use?' }] } }) + app.emit('b', 'turn/end', { reason: { kind: 'completed' } }) + await app.ctx.taskDrive.settled() + assert.equal(app.calls('war').length, 0, 'the own-chat reply is not posted into the group') + assert.equal(app.calls('b').length, 1, 'a wake that did not move the board does not start another') +}) - app.beginWake(sessionId) - app.emit(sessionId, 'assistant/message', { message: { content: [{ type: 'text', text: 'Which file should the note use?' }] } }) - app.emit(sessionId, 'turn/end', { reason: { kind: 'completed' } }) +test('progress stays on the card and the next assignee is woken without a group post (#1205)', async (t) => { + const app = await start(t) + const planned = await app.track('a', { tasks: [ + { title: 'Ship the note', overall: true, group: 'War' }, + { title: 'Write the draft', parent: 'Ship the note', assignee: 'Writer', group: 'War', priority: 'high' }, + ] }) + const write = app.idOf(planned, 'Write the draft') + assert.equal(app.calls('s').length, 1) + assert.equal(app.calls('s')[0].source.role, 'task') + assert.equal(app.calls('war').length, 0) + assert.equal(app.calls('b').length, 0) + + app.beginWake('s') + const moved = await app.track('s', { tasks: [{ id: write, column: 'in-progress', progress: 40, note: 'drafting' }] }) + assert.match(moved, /40/) + assert.equal(app.rt.state.tasks[write].progress, 40) + assert.equal(app.rt.state.tasks[write].note, 'drafting') + assert.equal(app.calls('s').length, 1, 'updating the open card does not queue another wake') + assert.equal(app.calls('war').length, 0, 'progress is not a group post') + + const handed = await app.track('a', { tasks: [ + { title: 'Check the facts', parent: 'Ship the note', assignee: 'BobBot', group: 'War', priority: 'urgent' }, + ] }) + const check = app.idOf(handed, 'Check the facts') + assert.equal(app.rt.state.tasks[check].status, 'claimed') + assert.equal(app.calls('b').length, 1, 'the next card wakes its assignee') + assert.equal(app.calls('b')[0].source.role, 'task') + assert.match(app.text('b'), new RegExp(`Continue ${check} \\[urgent\\] Check the facts`)) + assert.equal(app.calls('war').length, 0, 'the handoff does not open a group round') + assert.equal(app.rt.state.rooms.war.sessions.b, undefined) + + await app.track('s', { tasks: [{ id: write, column: 'done', progress: 100 }] }) + app.emit('s', 'turn/end', { reason: { kind: 'completed' } }) await app.ctx.taskDrive.settled() - for (let i = 0; i < 20 && app.calls('war').length === 0; i += 1) await new Promise(resolve => setTimeout(resolve, 10)) - assert.equal(app.calls('war').length, 1, 'the reply is posted into the group') - assert.match(app.text('war'), /Which file should the note use\?/) - assert.equal(app.calls('war')[0].source.role, 'post') - assert.equal(app.calls('war')[0].source.late, true) - assert.equal(app.calls(sessionId).length, 1, 'a wake that did not move the board does not start another') + assert.equal(app.calls('war').length, 0, 'finishing the card does not post into the group') + assert.equal(app.calls('b').length, 1, 'Bob was already woken when the card was written') + assert.equal(app.calls('s').length, 1, 'Writer has no further card') }) test('a Cordis context provides taskDrive instead of assigning it', async (t) => {