From 06d22d643dcb5f99885ab2dfe458e965473aaef8 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Fri, 16 Oct 2026 20:50:40 +0100 Subject: [PATCH 1/2] feat(api): put answers given in the review on the form, and pick salary bands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the browser is paused for review, an {type: "answers"} command runs a short follow-up agent (like the submit one: password-scoped tools, never sends the form) that puts the user's answers on the page, with fill_answer for written ones. The run's record takes them as saved answers, the required-field check runs again, and the review gets fresh questions and notes; the first pass's summary, which still listed them as needing the user, gives way to what this pass did. A salary question with band options now gets the one band that holds the salary expectation ("£31,500" in "£30,000-£35,000"). On a boundary, or a range that straddles two bands, it's left for the user. A live run of the real model on a local practice form: the review's two answers (a convictions dropdown, an accuracy checkbox) went on the form. The answers eval now requires the salary band; three runs kept every fact within its labels and answered the band each time. --- README.md | 4 +- api/app/answers.py | 40 ++++++- api/app/browser_agent.py | 139 ++++++++++++++++++++---- api/evals/test_answers_eval.py | 2 +- api/tests/browser/test_browser_agent.py | 57 ++++++++++ api/tests/test_answers.py | 123 ++++++++++++++++++++- 6 files changed, 330 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e835590..3cd7aa4 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Long-running endpoints stream **Server-Sent Events (SSE)**. Protected endpoints | `POST` | `/jobs/cover-letter` | SSE: tailored cover letter | | `POST` | `/jobs/tailored-cv` | SSE: tailored, structured CV | | `POST` | `/apply` | SSE: browser application (fill → review → submit) | -| `POST` | `/browser-input/{session_id}` | Click/type/key/scroll/`submit`/`done`/`skip` into the live session | +| `POST` | `/browser-input/{session_id}` | Click/type/key/scroll/`submit`/`done`/`skip` into the live session, or `{type: answers}` to put answers you gave in the review on the form | | `GET` | `/browser-stream/{session_id}` | SSE: live JPEG frames (CDP screencast) | | `GET` / `POST` | `/applications` | List / create tracked applications | | `PATCH` | `/applications/{id}/status` | Update one of *your* applications' status + notes | @@ -200,7 +200,7 @@ Labels: **High ≥ 65 %**, **Medium 35–65 %**, **Low < 35 %**. **Job fit** (`s - **No telemetry** — browser-use's product telemetry (PostHog) and cloud sync default to on and would send each run's task prompt, actions and visited URLs to browser-use; `browser_agent.py` defaults `ANONYMIZED_TELEMETRY` / `BROWSER_USE_CLOUD_SYNC` to `false` and `docker-compose.yml` sets both. - **Network limits** — the agent's browser can't open raw-IP URLs, `localhost` or the internal service hostnames. - **CV upload** — the tailored CV PDF if you generated one, otherwise your uploaded original. -- **Questions** — the agent isn't given facts to answer from. It sends each page's questions to an `answer_questions` action (`answers.py`), which answers from your saved answers, then your persona, and drafts written ones, and says where each answer came from. A model maps each question to the fact that answers it, but the value comes from the fact, and code checks the reading (a cited fact, one of the form's options, a yes/no fact's direction, a UK fact not used for another country). Contact details, salary and equality answers reach that model only as "this fact exists". Questions nothing answers are left blank and listed first in the review, which shows every answer and its source. +- **Questions** — the agent isn't given facts to answer from. It sends each page's questions to an `answer_questions` action (`answers.py`), which answers from your saved answers, then your persona, and drafts written ones, and says where each answer came from. A model maps each question to the fact that answers it, but the value comes from the fact, and code checks the reading (a cited fact, one of the form's options, a yes/no fact's direction, a UK fact not used for another country). Contact details, salary and equality answers reach that model only as "this fact exists". Questions nothing answers are left blank and listed first in the review, which shows every answer and its source. What you answer there is saved, and while the browser is paused a short follow-up agent puts it on the form (like the one that clicks submit, it never sends the form). A salary question with bands gets the one band that holds your figure; on a boundary it's left for you. --- diff --git a/api/app/answers.py b/api/app/answers.py index f85ffb2..a9c4a1b 100644 --- a/api/app/answers.py +++ b/api/app/answers.py @@ -311,6 +311,35 @@ def pick_option(value: str, options: list[str]) -> str | None: return None +_AMOUNT = re.compile(r"(\d+(?:,\d{3})*(?:\.\d+)?)\s*(k\b|thousand\b)?", re.I) +_OPEN_TOP = re.compile(r"\+|\b(?:above|over|more than|or more|plus|at least|minimum)\b", re.I) +_OPEN_BOTTOM = re.compile(r"\b(?:up to|under|below|less than|or less|max(?:imum)?)\b", re.I) + + +def _money_range(text: str) -> tuple[float, float] | None: + """The amounts a salary answer or band states, as (low, high): "£30k-£35k", "£35,000+", "Up to £25,000".""" + amounts = [float(n.replace(",", "")) * (1000 if unit else 1) for n, unit in _AMOUNT.findall(text)] + if not amounts: + return None + if len(amounts) >= 2: + return min(amounts[:2]), max(amounts[:2]) + if _OPEN_TOP.search(text): + return amounts[0], float("inf") + if _OPEN_BOTTOM.search(text): + return 0.0, amounts[0] + return amounts[0], amounts[0] + + +def pick_band(value: str, options: list[str]) -> str | None: + """The one salary band that holds the whole of `value` ("£31,500" is in "£30,000-£35,000"); None when no + band or more than one does (31,500 on a band boundary, a range that straddles two, "Negotiable").""" + wanted = _money_range(value) + if not wanted: + return None + holding = [o for o in options if (band := _money_range(o)) and band[0] <= wanted[0] and wanted[1] <= band[1]] + return holding[0] if len(holding) == 1 else None + + _UK_RE = re.compile(r"\b(?:uk|u\.k\.|united kingdom|great britain|britain|british|england|english|scotland|" r"scottish|wales|welsh|northern ireland|gb)\b", re.I) # Bare "us" counts only in capitals ("the US"): "tell us", "join us". @@ -370,8 +399,12 @@ def resolve_fact(question: dict, reading: dict, facts: dict[str, Fact], job_loca first = cited[0] if len(cited) == 1 and first.private: - # The model never saw the value, so it can only have said which fact applies. - answer = pick_option(first.value, options) + # The model never saw the value, so it can only have said which fact applies. A salary goes in the band + # that holds it. + if first.id.startswith("salary_") and any(_money_range(o) for o in options): + answer = pick_band(first.value, options) + else: + answer = pick_option(first.value, options) return (answer, cited) if answer else None if options: @@ -419,7 +452,8 @@ def resolve_fact(question: dict, reading: dict, facts: dict[str, Fact], job_loca "- A fact marked (private) has its value hidden: cite its id when it is the fact asked for, and leave " "answer \"\"; Windrush fills in the value.\n" "- Don't compute or estimate: years of experience, counts, grades or dates are answered only by a fact " - "that states them.\n" + "that states them. The one exception: a salary question whose options are bands or ranges is answered by " + "the salary expectations fact; cite it and Windrush picks the band that holds the figure.\n" "- Read the question's direction: 'Will you require sponsorship?' and 'Can you work without " "sponsorship?' take opposite answers from the same fact.\n" "- A declaration or consent (a privacy notice, that the information is accurate, background or reference " diff --git a/api/app/browser_agent.py b/api/app/browser_agent.py index 3c76260..52332d6 100644 --- a/api/app/browser_agent.py +++ b/api/app/browser_agent.py @@ -360,6 +360,7 @@ async def _interactive_session( on_submit: Callable[[], Awaitable[tuple[bool, str]]], notes: str = "", questions: list[dict] | None = None, + on_answers: Callable[[list[dict]], Awaitable[dict]] | None = None, ) -> AsyncGenerator[dict, None]: """ Hand control to the user until they finish. Commands (from BrowserView.tsx): @@ -367,6 +368,8 @@ async def _interactive_session( 'submit' → a short agent clicks the final submit button (submitted if it confirms) 'done' → the user submitted it themselves 'skip' / 'cancel' / 'abort' → abandon (not submitted) + JSON {type: answers, answers: [{question, answer, kind, options}]} → `on_answers` puts the answers the + user gave in the review on the form; its result ({action, notes, questions}) is the next event any other text → typed into the focused field `notes` (the agent's own summary) and `questions` (what answer_questions answered) ride on the first event only; the UI keeps them for the review. @@ -399,6 +402,21 @@ def interactive(action: str, reason: str, screenshot: str) -> dict: pass word = (cmd.get("type", "") if cmd else str(raw)).strip().lower() + if word == "answers" and cmd is not None and on_answers is not None: + answers = _review_answers(cmd.get("answers")) + if not answers: + yield interactive("There were no answers to fill in", again, await _screenshot_b64(browser_session)) + continue + count = f"{len(answers)} answer{'' if len(answers) == 1 else 's'}" + yield {"action": f"Filling in your {count}…", "screenshot": None, "blocked": False, "reason": None, + "done": False} + update = await on_answers(answers) + event = interactive(update.get("action", f"Filled in your {count}"), again, + await _screenshot_b64(browser_session)) + event.update({k: update[k] for k in ("notes", "questions") if k in update}) + yield event + continue + if word == "submit": yield {"action": "Submitting the application…", "screenshot": None, "blocked": False, "reason": None, "done": False} @@ -690,6 +708,53 @@ async def guarded_act(action, browser_session, sensitive_data=None, **kwargs): return tools +# Answers the candidate gave in the review, put on the paused form by a short follow-up agent. +_MAX_REVIEW_ANSWERS = 20 +_FILL_TASK = ( + "The job application form on the current page has been filled in, and the candidate has now answered some " + "questions that were left blank (or rewritten an answer). Put each answer below on the form exactly as given: " + "choose the given option for a dropdown, radio or checkbox question, type the given text into a text box, and " + "use fill_answer for a written answer. Change nothing else, don't navigate away, and do NOT submit the form " + "or click any button that sends it. Then call done with success=true, or with success=false naming each " + "question you couldn't find on the page.\n\n" +) + + +def _review_answers(raw) -> list[dict]: + """The answers a review 'answers' command carries, checked: [{question, answer, kind, options}].""" + out = [] + for item in raw if isinstance(raw, list) else []: + if not isinstance(item, dict): + continue + question = " ".join(str(item.get("question") or "").split())[:500] + answer = str(item.get("answer") or "").strip()[:5000] + if not question or not answer: + continue + options = [str(o)[:200] for o in item.get("options") or [] if isinstance(o, str)][:300] + kind = "written" if item.get("kind") == "written" else "fact" + out.append({"question": question, "answer": answer, "kind": kind, "options": options}) + return out[:_MAX_REVIEW_ANSWERS] + + +async def _fill_with_agent(llm, browser_session, sensitive_data: dict | None, answers: list[dict], + secret: str = "") -> tuple[bool, str]: + """Run a short follow-up agent on the paused form that puts the review's answers in. Like the submit + agent it gets the password-scoped tools, plus fill_answer for the written answers.""" + numbered = {n: {**a, "source": "saved"} for n, a in enumerate(answers, 1)} + task = _FILL_TASK + "Answers:\n" + "\n".join(_answer_line(n, a) for n, a in numbered.items()) + tools = _secret_scoped_tools() + _add_fill_answer(tools, browser_session, numbered) + try: + agent = Agent(task=task, llm=llm, browser_session=browser_session, use_vision=False, use_judge=False, + directly_open_url=False, tools=tools, sensitive_data=sensitive_data) + history = await agent.run(max_steps=min(4 + 2 * len(answers), 16)) + return bool(history.is_successful()), _redact(history.final_result() or "", secret)[:300] + except Exception as exc: + error = _redact(str(exc), secret) + logger.warning("review fill agent failed: %s", error) + return False, error[:300] + + async def _submit_with_agent(llm, browser_session, sensitive_data: dict | None, secret: str = "") -> tuple[bool, str]: """Run a tiny follow-up agent on the same (kept-alive) browser to click the final submit. @@ -813,6 +878,31 @@ def _answer_line(n: int, result: dict) -> str: return f'{n}. "{question}": {verb} "{result["answer"]}"' +def _add_fill_answer(tools, browser_session, numbered: dict[int, dict]) -> None: + """Register fill_answer, which pastes answer `numbered[n]` whole into a text box.""" + @tools.action( + "Put a written answer into its text box in one step: give the box's index and the answer's number. It " + "replaces whatever the box holds.", + param_model=FillAnswerParams) + async def fill_answer(params: FillAnswerParams) -> ActionResult: + result = numbered.get(params.answer) + if not result or not result.get("answer"): + return ActionResult(error=f"There's no answer numbered {params.answer} to fill in.") + node = await browser_session.get_element_by_index(params.index) + if node is None: + return ActionResult(error=f"Element index {params.index} isn't available - the page may have changed.") + text = result["answer"] + try: + held = await _paste_text(browser_session, node, text) + except Exception as exc: + return ActionResult(error=f"Couldn't fill element {params.index}: {type(exc).__name__}: {exc}"[:300]) + if " ".join(held.split()) != " ".join(text.split()): + return ActionResult(error=f"Filled, but element {params.index} now holds {len(held)} characters, not " + f"the answer's {len(text)}: check it is the box for that question.") + done = f"Filled answer {params.answer} ({len(text)} characters) into element {params.index}." + return ActionResult(extracted_content=done, long_term_memory=done) + + def _apply_tools(cover_letter: str, browser_session, answerer: Answerer | None = None, asked: dict[str, dict] | None = None) -> "Tools": """The fill agent's tools: the password-scoped set, answer_questions and fill_answer (recording what @@ -846,28 +936,7 @@ async def answer_questions(params: AnswerQuestionsParams) -> ActionResult: text = "Answers:\n" + "\n".join(lines) return ActionResult(extracted_content=text, long_term_memory=text) - @tools.action( - "Put a written answer from answer_questions into its text box in one step: give the box's index and the " - "answer's number. It replaces whatever the box holds.", - param_model=FillAnswerParams) - async def fill_answer(params: FillAnswerParams) -> ActionResult: - result = numbered.get(params.answer) - if not result or not result.get("answer"): - return ActionResult(error=f"There's no answer numbered {params.answer} to fill in.") - node = await browser_session.get_element_by_index(params.index) - if node is None: - return ActionResult(error=f"Element index {params.index} isn't available - the page may have changed.") - text = result["answer"] - try: - held = await _paste_text(browser_session, node, text) - except Exception as exc: - return ActionResult(error=f"Couldn't fill element {params.index}: {type(exc).__name__}: {exc}"[:300]) - if " ".join(held.split()) != " ".join(text.split()): - return ActionResult(error=f"Filled, but element {params.index} now holds {len(held)} characters, not " - f"the answer's {len(text)}: check it is the box for that question.") - done = f"Filled answer {params.answer} ({len(text)} characters) into element {params.index}." - return ActionResult(extracted_content=done, long_term_memory=done) - + _add_fill_answer(tools, browser_session, numbered) if not cover_letter.strip(): return tools @@ -1111,10 +1180,32 @@ async def on_step(browser_state, agent_output, step_number): "blocked": False, "reason": None, "done": True} return + summary = notes empty = await _empty_required_fields(browser_session) questions = list(asked.values()) left_blank = [q["question"] for q in questions if q["source"] == "needs_you"] - notes = _with_empty_fields(notes, empty, left_blank)[:_MAX_NOTES_CHARS] + notes = _with_empty_fields(summary, empty, left_blank)[:_MAX_NOTES_CHARS] + + async def put_answers_on_form(answers: list[dict]) -> dict: + ok, detail = await _fill_with_agent(llm, browser_session, sensitive_data, answers, job_password) + for a in answers: + key = _question_key(a["question"]) + asked[key] = {**asked.get(key, {"bank_id": ""}), "question": asked.get(key, a)["question"], + "options": a["options"], "kind": a["kind"], "source": "saved", "answer": a["answer"], + "because": "Your answer in this review", "flags": []} + questions = list(asked.values()) + blank = [q["question"] for q in questions if q["source"] == "needs_you"] + still_empty = await _empty_required_fields(browser_session) + count = f"{len(answers)} answer{'' if len(answers) == 1 else 's'}" + if ok: + action = f"Filled in your {count} — check them on the page before you submit" + else: + action = f"Couldn't put every answer on the form{': ' + detail if detail else ''} — check the page" + # The first pass's summary still lists these questions as needing you, so it gives way to what + # this pass did. + done = f"From your review: {detail}" if detail else "" + return {"action": action[:300], "questions": questions, + "notes": _with_empty_fields(done, still_empty, blank)[:_MAX_NOTES_CHARS]} see_notes = " — see the agent's notes" if notes else "" if agent_error: intro = f"The agent hit a problem — handing control to you ({agent_error})" @@ -1137,7 +1228,7 @@ async def on_step(browser_state, agent_output, step_number): async for step in _interactive_session( browser_session, instruction_queue, intro, reason, on_submit=lambda: _submit_with_agent(llm, browser_session, sensitive_data, job_password), - notes=notes, questions=questions, + notes=notes, questions=questions, on_answers=put_answers_on_form, ): yield step if step.get("done"): diff --git a/api/evals/test_answers_eval.py b/api/evals/test_answers_eval.py index 1f62130..cd21daa 100644 --- a/api/evals/test_answers_eval.py +++ b/api/evals/test_answers_eval.py @@ -67,7 +67,7 @@ def _entry(n, question, answer, kind="fact", company=""): ("What is your nationality?", ["British", "Irish", "Other"], {"British"}), ("What is your notice period?", [], {"2 weeks"}), ("What are your salary expectations?", [], {"£31,500"}), - ("Expected salary band", ["£25,000-£30,000", "£30,000-£35,000", "£35,000+"], {"£30,000-£35,000", NEEDS}), + ("Expected salary band", ["£25,000-£30,000", "£30,000-£35,000", "£35,000+"], {"£30,000-£35,000"}), ("Gender", ["Male", "Female", "Non-binary", "Prefer not to say"], {"Female"}), ("Do you consider yourself to have a disability?", ["Yes", "No", "Prefer not to say"], {"Prefer not to say"}), ("Are you 18 years of age or older?", YN, {"Yes"}), diff --git a/api/tests/browser/test_browser_agent.py b/api/tests/browser/test_browser_agent.py index 4df7293..9af2a70 100644 --- a/api/tests/browser/test_browser_agent.py +++ b/api/tests/browser/test_browser_agent.py @@ -358,3 +358,60 @@ async def flow(): "- Do you have any unspent criminal convictions?\n\n" "Required fields still empty on the form:\n- Will you require visa sponsorship?") assert_no_chromium_left() + + +class ReviewFillLLM(QuestionsLLM): + """QuestionsLLM, then as the review-fill agent: picks the candidate's answer in the dropdown, reads it back + with JS, then finishes.""" + + def __init__(self): + super().__init__() + self.review_step = 0 + + def next_action(self, text: str) -> list[dict]: + if "has now answered some" not in text: + return super().next_action(text) + self.review_step += 1 + select = re.search(r"\[(\d+)\], answer=2)" in task and "do NOT submit" in task + assert "fill_answer" in fills[0]["tools"].registry.registry.actions + + reviews = [e for e in events if e.get("interactive")] + assert len(reviews) == 2 + after = reviews[1] + assert after["action"].startswith("Filled in your 2 answers") + by_question = {q["question"]: q for q in after["questions"]} + convictions = by_question["Have you ever been convicted of a criminal offence?"] + assert (convictions["source"], convictions["answer"]) == ("saved", "No") + assert by_question["Why do you want to join us?"]["kind"] == "written" + assert by_question["Do you have the right to work in the UK?"]["source"] == "profile" # untouched + # Nothing is left for you, so the notes no longer lead with it, and the first pass's summary (which still + # listed these as needing you) gives way to what this pass did. + assert not after["notes"].startswith("Questions only you can answer") + assert after["notes"].endswith("From your review: Filled the form.") + assert reviews[0]["notes"].endswith("Filled the form.") and "From your review" not in reviews[0]["notes"] + + +def test_a_fill_that_fails_says_so_and_the_review_carries_on(monkeypatch): + async def failing(*args, **kwargs): + return False, "Couldn't find the convictions question" + monkeypatch.setattr(ba, "_fill_with_agent", failing) + given = json.dumps({"type": "answers", "answers": [{"question": "Notice period?", "answer": "2 weeks"}]}) + events = _run_with_questions(monkeypatch, None, [ba.FormQuestion(question="Notice period?")], + commands=(given, json.dumps({"type": "answers", "answers": []}), "skip")) + reviews = [e for e in events if e.get("interactive")] + assert reviews[1]["action"].startswith("Couldn't put every answer on the form: Couldn't find the convictions") + assert reviews[2]["action"] == "There were no answers to fill in" + assert events[-1]["action"] == "Cancelled by user" + + +def test_the_apply_stream_stores_the_questions_again_after_a_fill(client, user, monkeypatch): + async def public(url): + return None + monkeypatch.setattr(net_guard, "assert_public_url", public) + before = [{"question": "Notice period?", "options": [], "kind": "fact", "source": "needs_you", "answer": "", + "because": "", "bank_id": "", "flags": []}] + after = [{**before[0], "source": "saved", "answer": "2 weeks", "because": "Your answer in this review"}] + + async def fake_apply_with_browser(*args, **kwargs): + yield {"action": "Form filled", "screenshot": None, "blocked": True, "reason": "", "done": False, + "interactive": True, "questions": before} + yield {"action": "Filled in your 1 answer", "screenshot": None, "blocked": True, "reason": "", "done": False, + "interactive": True, "questions": after} + yield {"action": "Cancelled by user", "screenshot": None, "blocked": False, "reason": None, "done": True} + monkeypatch.setattr(agent, "apply_with_browser", fake_apply_with_browser) + + client.post("/apply", data={"job_id": "j", "job_title": "T", "company": "C", "job_url": "https://jobs.example.com/1"}) + assert tracker.list_applications(user)[0]["questions"] == after From 3ed30061e8fc0d390945fa1320217ce66e298f26 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Fri, 16 Oct 2026 20:50:40 +0100 Subject: [PATCH 2/2] feat(frontend): fill review answers in on the form from the questions panel Answers you save in the live review (a question left blank, or a draft you rewrote) wait in the questions panel with "Fill them in on the form", which sends them to the paused agent. The panel clears them once the agent reports back with the record updated. A smoke test drives a live review with the apply stream held open. --- frontend/e2e/smoke.spec.ts | 85 +++++++++++++++++++++++ frontend/src/app/jobs/[id]/page.tsx | 8 ++- frontend/src/components/FormQuestions.tsx | 70 +++++++++++++++++-- 3 files changed, 156 insertions(+), 7 deletions(-) diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 30501c4..e0b3a41 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -335,3 +335,88 @@ test("saved answers can be edited and deleted from your persona", async ({ page await expect(page.getByText("Nothing saved yet.", { exact: false })).toBeVisible(); api.expectAllMocked(); }); + +test("an answer given in the live review is put on the form by the agent", async ({ page }) => { + await signIn(page); + const job = { ...JOBS[0], description: "Python and FastAPI services." }; + await page.addInitScript((j) => sessionStorage.setItem("job_1", JSON.stringify(j)), job); + // The apply stream stays open for the whole review, which a mocked route can't do: stub it in the page, + // with window.__pushApply(event) sending the agent's next event. + await page.addInitScript(() => { + const realFetch = window.fetch.bind(window); + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (!url.includes("/api/apply")) return realFetch(input, init); + const encode = (ev: object) => new TextEncoder().encode(`data: ${JSON.stringify(ev)}\n\n`); + const w = window as unknown as { __pushApply: (ev: object) => void; __applyBody: string }; + w.__applyBody = init?.body instanceof FormData ? Array.from(init.body.keys()).join(",") : ""; + return Promise.resolve(new Response(new ReadableStream({ + start(controller) { w.__pushApply = (ev) => controller.enqueue(encode(ev)); }, + }), { status: 200, headers: { "content-type": "text/event-stream" } })); + }; + }); + const cv = { name: "Sam Okafor", contact: {}, summary: "Graduate.", skills: [], experience: [], education: [], + projects: [], certifications: [] }; + const letter = { candidate_name: "Sam Okafor", salutation: "Dear Hiring Manager,", paragraphs: ["I build services."], + signoff: "Yours faithfully," }; + const check = { keywords: { matched: [], missing_have: [], gaps: [], in_ad: 0, score: null }, facts: [], style: [], ats: null }; + const inputs: { instruction: string }[] = []; + const saved: unknown[] = []; + const api = await mockApi(page, { + ...SHELL, + "GET /api/jobs/1/sponsorship": { sponsorship: "offered", evidence: "", clearance: "", licensed_sponsor: false, sponsor_name: "", mark: "" }, + "POST /api/jobs/analyze": { sse: [{ type: "job_fit", ranked_jobs: [{ fit_score: 84, level_match: "strong", + matched_skills: ["Python"], skill_gaps: [], reasoning: "Strong backend overlap." }] }] }, + "POST /api/jobs/tailored-cv": { sse: [{ type: "tailored_cv", cv }] }, + "POST /api/jobs/cover-letter": { sse: [{ type: "cover_letter", letter }] }, + "POST /api/documents/preview": { html: "Doc" }, + "POST /api/documents/check": check, + "POST /api/documents/pdf": { doc_id: "0".repeat(32) }, + "GET /api/browser-stream/sid": { sse: [] }, + "POST /api/answers": (request) => { + saved.push(request.postDataJSON()); + return { id: "b1", question: "q", answer: "No", kind: "fact", company: "", created_at: "", updated_at: "" }; + }, + "POST /api/browser-input/sid": (request) => { + inputs.push(request.postDataJSON()); + return { queued: true }; + }, + }); + await page.goto("/jobs/1"); + await page.getByRole("button", { name: "Continue to tailored CV" }).click(); + await page.getByRole("button", { name: /write the cover letter/ }).click(); + await page.getByRole("button", { name: /set up the agent/ }).click(); + await page.getByRole("button", { name: "Start the agent" }).click(); + + const blank = { question: "Do you have any unspent criminal convictions?", options: ["Yes", "No"], kind: "fact", + source: "needs_you", answer: "", because: "Nothing you've saved says this", bank_id: "", flags: [] }; + await page.waitForFunction(() => "__pushApply" in window); + expect(await page.evaluate(() => (window as unknown as { __applyBody: string }).__applyBody)).toContain("job_description"); + await page.evaluate((q) => { + const push = (window as unknown as { __pushApply: (ev: object) => void }).__pushApply; + push({ type: "start", session_id: "sid", timestamp: 1 }); + push({ type: "browser_blocked", action: "Form filled, but 1 question only you can answer", interactive: true, + reason: "Answer it", questions: [q], timestamp: 2 }); + }, blank); + + const panel = page.getByRole("region", { name: "Questions on this form" }); + await panel.getByLabel("Do you have any unspent criminal convictions?").selectOption("No"); + await panel.getByRole("button", { name: "Save answer" }).click(); + await expect.poll(() => saved.length).toBe(1); + await panel.getByRole("button", { name: "Fill it in on the form" }).click(); + await expect.poll(() => inputs.length).toBe(1); + expect(JSON.parse(inputs[0].instruction)).toEqual({ type: "answers", answers: [ + { question: blank.question, answer: "No", kind: "fact", options: ["Yes", "No"] }] }); + await expect(panel).toContainText("The agent is putting your answer on the form"); + + // The agent reports back with the record updated: the prompt to fill it goes away. + await page.evaluate((q) => { + const push = (window as unknown as { __pushApply: (ev: object) => void }).__pushApply; + push({ type: "browser_blocked", action: "Filled in your 1 answer — check them on the page before you submit", + interactive: true, reason: "", timestamp: 3, + questions: [{ ...q, source: "saved", answer: "No", because: "Your answer in this review" }] }); + }, blank); + await expect(panel).toContainText("Your answer in this review"); + await expect(panel.getByRole("button", { name: /Fill it in on the form/ })).toHaveCount(0); + api.expectAllMocked(); +}); diff --git a/frontend/src/app/jobs/[id]/page.tsx b/frontend/src/app/jobs/[id]/page.tsx index 6e51217..5efd6c4 100644 --- a/frontend/src/app/jobs/[id]/page.tsx +++ b/frontend/src/app/jobs/[id]/page.tsx @@ -748,7 +748,13 @@ export default function JobDetailPage() { {review} {review && ( + savedNote={sessionId && !ended ? "Saved for your next form." : "Saved for your next form. Fill it in on the page too, then submit."} + onFill={sessionId && !ended ? async (answers) => { + if (!browserInteractive) throw new Error("Wait until the agent has paused on the form, then try again."); + await sendInstruction(sessionId, { type: "answers", answers }); + setBrowserBlocked(false); + setBrowserInteractive(false); + } : undefined} /> )}
diff --git a/frontend/src/components/FormQuestions.tsx b/frontend/src/components/FormQuestions.tsx index 5d9328b..3b5894d 100644 --- a/frontend/src/components/FormQuestions.tsx +++ b/frontend/src/components/FormQuestions.tsx @@ -1,8 +1,8 @@ "use client"; -import { useId, useState } from "react"; +import { useEffect, useId, useState } from "react"; import Icon from "./ui/Icon"; -import { buttonClass, Card, Chip, inputClass, Spinner, textareaClass, type ChipTone } from "./ui/kit"; +import { buttonClass, Callout, Card, Chip, inputClass, Spinner, textareaClass, type ChipTone } from "./ui/kit"; import { saveAnswer, SOURCE_LABEL } from "@/lib/answers"; import type { AnswerSource, FormAnswer } from "@/lib/types"; @@ -18,7 +18,14 @@ function count(source: AnswerSource, n: number): string { } } -function Row({ q, company, savedNote }: { q: FormAnswer; company: string; savedNote: string }) { +/** An answer you gave in the review, for the agent to put on the form. */ +export interface ReviewAnswer { question: string; answer: string; kind: FormAnswer["kind"]; options: string[] } + +function Row({ q, company, savedNote, onSaved }: { + q: FormAnswer; company: string; savedNote: string; + /** Called once the answer is saved, when it differs from what the agent put on the form. */ + onSaved: (a: ReviewAnswer) => void; +}) { const [value, setValue] = useState(q.answer); const [editing, setEditing] = useState(q.source === "needs_you"); const [state, setState] = useState<"idle" | "saving" | "saved">("idle"); @@ -32,9 +39,13 @@ function Row({ q, company, savedNote }: { q: FormAnswer; company: string; savedN setState("saving"); setError(null); try { - await saveAnswer(q.question, value.trim(), q.kind, company); + const answer = value.trim(); + await saveAnswer(q.question, answer, q.kind, company); setState("saved"); setEditing(false); + if (q.source === "needs_you" || answer !== q.answer.trim()) { + onSaved({ question: q.question, answer, kind: q.kind, options: q.options }); + } } catch (e) { setState("idle"); setError(e instanceof Error ? e.message : "Couldn't save the answer."); @@ -112,13 +123,44 @@ function Row({ q, company, savedNote }: { q: FormAnswer; company: string; savedN * answer bank, your persona, or drafted for this job), and a box for those only you can answer. * What you answer here goes into your answer bank, so the next form gets it. */ -export default function FormQuestions({ questions, company, savedNote }: { +export default function FormQuestions({ questions, company, savedNote, onFill }: { questions: FormAnswer[]; company: string; /** Shown once you've answered a question the agent left blank. */ savedNote: string; + /** While the agent's browser is paused on the form: have it put your answers on the form. */ + onFill?: (answers: ReviewAnswer[]) => Promise; }) { + // Answers saved in this review that aren't on the form yet, by question. + const [pending, setPending] = useState>({}); + const [filling, setFilling] = useState(false); + const [fillError, setFillError] = useState(null); + + // The agent sends the questions again once it has put your answers on the form: those are done. + // (Callers may pass a new array on every render, so nothing changes unless an answer really went in.) + useEffect(() => { + setFilling(false); + setPending((prev) => { + const left = Object.entries(prev).filter(([question, a]) => + !questions.some((q) => q.question === question && q.source === "saved" && q.answer === a.answer)); + return left.length === Object.keys(prev).length ? prev : Object.fromEntries(left); + }); + }, [questions]); + if (questions.length === 0) return null; + const waiting = Object.values(pending); + + const fill = async () => { + if (!onFill || waiting.length === 0) return; + setFilling(true); + setFillError(null); + try { + await onFill(waiting); + } catch (e) { + setFilling(false); + setFillError(e instanceof Error ? e.message : "Couldn't reach the agent."); + } + }; const sorted = [...questions].sort((a, b) => ORDER.indexOf(a.source) - ORDER.indexOf(b.source)); const counts = ORDER.map((s) => [s, questions.filter((q) => q.source === s).length] as const).filter(([, n]) => n > 0); @@ -136,8 +178,24 @@ export default function FormQuestions({ questions, company, savedNote }: { {counts.map(([s, n]) => {count(s, n)})}
+ {onFill && waiting.length > 0 && ( +
+ + {filling + ? `The agent is putting your ${waiting.length === 1 ? "answer" : `${waiting.length} answers`} on the form…` + : `${waiting.length} saved ${waiting.length === 1 ? "answer isn't" : "answers aren't"} on the form yet.`} + + +
+ )} + {fillError && {fillError}}
    - {sorted.map((q) => )} + {sorted.map((q) => ( + setPending((prev) => ({ ...prev, [a.question]: a }))} /> + ))}
);