Repository navigation
Make Start hand an email off, and the brief and Tasks say what is true - #73
jerelvelarde wants to merge 2 commits into
Conversation
…and-offs, deliberate next steps - The brief reads the connect refusal instead of printing its marker, names the shared Google connection once with the connect card under it, and says which sources it read so a quiet inbox reads as one. - Start on a brief row opens a task for that email on the server, keyed by its message id, so the row reads Started from the person's tasks after reloads and on other devices, and the ask names the email by id rather than repeating what its sender wrote. - A task's turn reports its status and next step through task_status; the prose fallback skips headings over prepared lists and treats a reply that waits on the person as needing input. - The brief turn's text is held to one sentence, and a task's report shows the pages it made.
Security reviewReviewed the full diff for injection, authorisation, data exposure and new attack surface. No blocking findings. One residual risk is noted below. M5, email text as the person's words: closed for Start. Before this branch, Start sent Adjacent hardening. The New route. Connect prompts in the brief. The server reads a
Report pages and the transcript. Page summaries come from the task's own thread history, which the report reader already reads for the owner only. The app validates them with the page schema, and links them through router params or Residual risk (not changed here). If a task never reaches the queue, Try again uses its title as the ask. That path existed before and every asked task shares it. For a brief-started task, the title contains the sender and subject. In practice it only matters if the queue insert fails right after the task row is written, and the approval gate still stands in front of any outgoing action. Closing it would mean changing the failed-turn fallback in Secrets. None added, logged or printed. |
…ief-tasks # Conflicts: # openbot/app/src/components/shell/morning-brief.tsx # openbot/shared/morning-brief.ts
The problem
A release review ran the morning brief and Tasks end to end with a real model, and found each surface saying something that was not true. With Gmail not connected, the brief's Couldn't check section printed the plugin store's raw refusal,
[connect-app]{"serverId":"google-workspace",…} You have not connected…, once for Gmail and again for Google Calendar, and since #65 that brief posts itself every day. With Gmail connected and a genuinely quiet inbox, the card showed only Today and the Bot said no messages were checked, so it was not an all-clear: the tool reportedchecked.mail: 0and nothing that distinguished read-and-empty from not read.Start on a brief row sent "Take care of Ana's email: Friday ship date?" into Chat as the person's message. The model answered it inline with a question and chips, so no task was made, while the row read Started from component state that a reload forgot, inviting a second press. That same message also put a sender's own subject line in front of the model as the person's words.
On Tasks, a row's status and next step were read from whatever paragraph the Bot's reply ended on: "Ready for review · Where the website lives now." was a section heading, "· Priya's other email says your Q2 return is already filed…" a remark, and "If you send me the details, I'll fill in the labels" read Ready for review because it asked without a question mark. The brief's own sentence after the card ran to two, and a task's report said "I made a page…" with nothing to open.
The approach
The brief reads the refusal instead of printing it.
brief.tsparses the connect marker for the app it called, and only that app, so a connector's error text cannot conjure a button for something else. The gap becomes one plain line, "Gmail and Google Calendar · Not connected yet.", once for the shared connection, carrying the prompt's fields. The app draws the sameAppAccessCarda refused call draws in the transcript under the card, which reads live state before offering Connect. Briefs already stored with the marker are read the same way in the app, so scrolling back does not show it either. The brief now says which sources itread, and the card says "Nothing new came in to your inbox." only for a source that was read; one that was not stays under Couldn't check. Its result is also marked as untrusted data for the model, since senders, subjects and titles are other people's words.Start opens a task, on the server. Start on a check-in card already starts its task through the engine rather than asking the model, and the README promises Start "hands that email to OpenDuet", so Start now posts the email's id to
POST /api/openduet/tasks/email. The server writes the ask itself, a fixed sentence plus the id, and the Bot reads the email with its Gmail tool, whose answer arrives marked as outside content. The sender and subject go only into the title, which the turn never sees. The task carries the message id in a new nullablesource_refcolumn, with a partial unique index per person, app and id, so a row reads Started exactly when a task for that email exists: after a reload, in another tab, on a phone. A second press, even a simultaneous one, answers with the first task. The cost is a migration and that Chat no longer gets an "On it" line, so the row's Started and the Tasks tab are where the hand-off shows. Anything the task sends still stops for Allow once.A task says where it stands. A new
task_statustool takesneeds_youordoneand one line of at most 120 characters. The turn runner hands back a turn's calls to tools it names (reportTools), so the task turn records that line and that flag in the columns the prose reading already wrote, and existing tasks read exactly as before. The transcript draws no row for the call, the pill does not name it, and the reply-chips guard does not count it as a read, which would otherwise have held a finished answer's chips. Where the Bot does not call it,replyEndingstill decides, now passing over headings above a prepared list and treating "if you send me the details, I'll…" as waiting on the person. That changes one pinned expectation: "Paste the key terms here and I'll go through them with you." now asks.One sentence after the brief. The frame now names the shape, exactly one sentence of at most twenty words with no offer of help, and the old frame stays readable for briefs already posted. The tool's description, which had asked for two sentences, now matches it. On a brief the app posted itself, the transcript draws the Bot's first whole sentence after the card and never cuts one in half.
The report shows what the task made. The report lists the pages its
make_pagecalls saved, each once at its latest save, under Made, as the same card Chat draws.What is not covered
task_statusreliably is unmeasured; the prose fallback covers turns that do not.Verification
openduet-task-threads.integration.test.tsalone: 71 pass, 0 fail.channel-summaryfails only in the shared process and passes alone.bun run typecheckexit 0;bun run typecheck:testsclean against its baseline (240 known);biome lint --error-on-warnings0 errors;biome formatclean;drizzle-kit checkfine andgeneratereports no further changes.New tests:
openduet-task-status.test.tsholds the tool's contract and that the last valid call wins;turn-settle.test.tsthat refused and encoded refusals report nothing;openduet-task-turn.test.tsthat the reported line and flag are recorded over the prose; the brief tests hold the marker parsing, the dedupe, the marker from another app being refused, andread; the integration test holds the email route's ask, idempotency under two simultaneous presses, and its refusal of a non-id; the app tests hold Started surviving a remount from the task list.Merge notes
This adds migration
0068_task_source_ref; a branch landing another 0068 first means regenerating this one.TurnRunnergains an optionalreportToolsinput andreportsoutput, andTaskRecordasourceReffield, both additive.