Repository navigation
fix(web): show the open question card again after returning to a project #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
669c565
2d1fdc1
761dca1
5a681df
89d90b5
105f0a5
01e8fe0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import type { AskQuestions } from "@corbits-builder/schemas/questions"; | ||
| import { lastOpenAsk } from "./coordinator-question.ts"; | ||
|
|
||
| const person = "b65b0e2f4c4a444db11b7321dc413964@sunny-harbor-panda.localhost"; | ||
| const run = "run_04ba732b8686649f77a2797393bafbc5@sunny-harbor-panda.localhost"; | ||
| const questions: AskQuestions["questions"] = [ | ||
| { | ||
| id: "audience", | ||
| prompt: "Who is this for?", | ||
| kind: "single", | ||
| options: [{ value: "me", label: "Just me" }], | ||
| }, | ||
| ]; | ||
|
|
||
| const inbound = (from: string, text: string, timestamp: number) => ({ | ||
| role: "user", | ||
| content: [{ type: "text", text: `[From: ${from}]\n\n${text}` }], | ||
| timestamp, | ||
| }); | ||
| const ask = (id: string, args: unknown, timestamp: number) => ({ | ||
| role: "assistant", | ||
| content: [{ type: "tool_call", id, name: "ask_questions", arguments: args }], | ||
| timestamp, | ||
| }); | ||
| const asked = [ | ||
| inbound(person, "A habit tracker", 1), | ||
| ask("call_1", { questions }, 2), | ||
| ]; | ||
| const open = { callId: "call_1", arguments: { questions } }; | ||
|
|
||
| test("the person's next turn answers the ask", () => { | ||
| expect( | ||
| lastOpenAsk([...asked, inbound(person, "Just me", 3)], undefined), | ||
| ).toBeUndefined(); | ||
| }); | ||
|
|
||
| test("a workflow run's mail leaves the ask open", () => { | ||
| expect( | ||
| lastOpenAsk([...asked, inbound(run, "The brief is filed.", 3)], undefined), | ||
| ).toEqual(open); | ||
| }); | ||
|
|
||
| test("an approval decision's note leaves the ask open", () => { | ||
| const declined = JSON.stringify({ outcome: "rejected", message: "Not yet" }); | ||
| expect( | ||
| lastOpenAsk([...asked, inbound("signal@local", declined, 3)], undefined), | ||
| ).toEqual(open); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,19 @@ import { join } from "node:path"; | |
| import { getLogger } from "@intx/log"; | ||
| import { reconstructDurableConversation } from "@intx/workflow-host"; | ||
| import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy"; | ||
| import { | ||
| AskQuestions, | ||
| askQuestionsIssue, | ||
| type OpenAsk, | ||
| } from "@corbits-builder/schemas/questions"; | ||
|
|
||
| import { errorCode, requestErrorMetadata } from "../request-error.ts"; | ||
| import { withCommittedAgentState } from "./committed-agent-state.ts"; | ||
| import { | ||
| isApprovalDecision, | ||
| isRecord, | ||
| mapMirrorConversationTurns, | ||
| } from "./conversation-turns.ts"; | ||
|
|
||
| const log = getLogger(["corbits-builder", "hub", "coordinator-question"]); | ||
|
|
||
|
|
@@ -23,8 +33,16 @@ export type CoordinatorRepoDirSource = { | |
| type CoordinatorQuestionProse = Readonly<{ | ||
| prose: string; | ||
| source: Readonly<{ kind: "coordinator-turn"; agentKey: string }>; | ||
| ask?: OpenAsk; | ||
| }>; | ||
|
|
||
| type Asking = { | ||
| prose: string; | ||
| agentKey: string; | ||
| timestamp: number; | ||
| ask?: OpenAsk | undefined; | ||
| }; | ||
|
|
||
| export function createCoordinatorQuestionProseReader(options: { | ||
| repoStore: CoordinatorRepoDirSource; | ||
| }) { | ||
|
|
@@ -43,18 +61,14 @@ export function createCoordinatorQuestionProseReader(options: { | |
| typeof parkedAt === "string" && Number.isFinite(Date.parse(parkedAt)) | ||
| ? Date.parse(parkedAt) | ||
| : undefined; | ||
| let best: | ||
| | { prose: string; agentKey: string; timestamp: number } | ||
| | undefined; | ||
| let best: Asking | undefined; | ||
| try { | ||
| best = await withCommittedAgentState(repoDir, async (agentStateRoot) => { | ||
| const entries = await readdir(agentStateRoot, { withFileTypes: true }); | ||
| const keys = entries | ||
| .filter((entry) => entry.isDirectory()) | ||
| .map((entry) => entry.name); | ||
| let found: | ||
| | { prose: string; agentKey: string; timestamp: number } | ||
| | undefined; | ||
| let found: Asking | undefined; | ||
| for (const agentKey of keys) { | ||
| let turns: unknown[]; | ||
| try { | ||
|
|
@@ -88,6 +102,8 @@ export function createCoordinatorQuestionProseReader(options: { | |
| timestamp: candidate.timestamp, | ||
| }; | ||
| } | ||
| if (found?.agentKey === agentKey) | ||
| found.ask = lastOpenAsk(turns, deadline); | ||
| } | ||
| return found; | ||
| }); | ||
|
|
@@ -102,6 +118,7 @@ export function createCoordinatorQuestionProseReader(options: { | |
| return { | ||
| prose: best.prose, | ||
| source: { kind: "coordinator-turn", agentKey: best.agentKey }, | ||
| ...(best.ask === undefined ? {} : { ask: best.ask }), | ||
| }; | ||
| }; | ||
| } | ||
|
|
@@ -130,3 +147,49 @@ function assistantText( | |
| if (text.trim() === "") return undefined; | ||
| return { text, timestamp: record.timestamp }; | ||
| } | ||
|
|
||
| /** | ||
| * The last ask_questions call the tool accepted after the person's last message; that message answers | ||
| * it. An approval decision answers its own card, not the questions. | ||
| */ | ||
| export function lastOpenAsk( | ||
| turns: unknown[], | ||
| deadline: number | undefined, | ||
| ): OpenAsk | undefined { | ||
| const asked = turns.filter( | ||
| (turn): turn is Record<string, unknown> & { timestamp: number } => | ||
| isRecord(turn) && | ||
| typeof turn["timestamp"] === "number" && | ||
| (deadline === undefined || turn["timestamp"] <= deadline), | ||
| ); | ||
| const answer = mapMirrorConversationTurns( | ||
| asked.filter((turn) => !isApprovalDecision(turn)), | ||
| "note", | ||
| ).findLast((turn) => turn.role === "person"); | ||
|
Comment on lines
+165
to
+168
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 · Hub's specialist-ended notice counts as the person's answer and hides the open card
The coordinator calls ask_questions while a specialist is running. The specialist fails, so the hub sends its "Status update: …" notice. The coordinator acknowledges it and parks for input again. The person then reloads the page. Resolve this comment. Triage will not finish while it is open. |
||
| let found: OpenAsk | undefined; | ||
| for (const turn of asked) { | ||
| if (answer !== undefined && turn.timestamp <= answer.at) continue; | ||
| found = acceptedAsk(turn) ?? found; | ||
| } | ||
| return found; | ||
| } | ||
|
|
||
| /** The turn's last ask_questions call that passes the same checks the tool makes. */ | ||
| function acceptedAsk(turn: Record<string, unknown>): OpenAsk | undefined { | ||
| if (turn["role"] !== "assistant" || !Array.isArray(turn["content"])) | ||
| return undefined; | ||
| let found: OpenAsk | undefined; | ||
| for (const block of turn["content"]) { | ||
| if ( | ||
| !isRecord(block) || | ||
| block["type"] !== "tool_call" || | ||
| block["name"] !== "ask_questions" || | ||
| typeof block["id"] !== "string" | ||
| ) | ||
| continue; | ||
| const input = block["arguments"]; | ||
| if (AskQuestions.allows(input) && askQuestionsIssue(input) === undefined) | ||
| found = { callId: block["id"], arguments: input }; | ||
| } | ||
| return found; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.