Ask's card remembers what it did, step by step, on the answer that proposed it (#53): the card kept its progress only in the page that clicked it, so back in Chat the same answer offered Start session again, and a second click on the new-repository card opened a second issue and started a second session on the same empty repository (flagon-io/ask-hello-test got #1 and #2, each with a session racing to push the first version). What a click does is now kept on the @g1t answer as it happens, in a proposal_state step beside the proposal: chat's new g1t_proposal merges it in (the viewer's own direct message with g1t only, known fields only) and tells the room, so every open copy of the answer updates. The card starts from it wherever the answer is read: Created with the repository, Live once the first version is pushed at with the address or Not deployed: with why, and Started with its links. Another click carries on from there rather than over: a session already started is shown, not started again; a repository already made, its deployments and the issue already opened are used; and while a click is still starting it (90 seconds at most), another says it's already starting. A failed step clears that hold so a retry works. Ask's steps also stop marking a read that found nothing as not read: checking that a new repository's name is free answers "not found", which showed as 1 not read on every new-repository answer.
- Checked:
- chat's tests (84, with withProposalState merging step by step, dropping unknown fields and saying what is kept), its typecheck; the site's typecheck (tsc -b --force), ask.test.ts (4, with kept state folding back), react-router build and the deploy tool's tests (92); cargo test -p g1t-integrations (76, with is_not_found). The end-to-end run before this change: Home's ask in flagon-io proposed Build "Hello world page" in flagon-io/ask-hello-test, Start session created the repository, turned on deployments and started the session, and https://ask-hello-test-flagonio.g1t.page served Hello from g1t about two minutes later.
- G1t-Session:
- ses_01m4ype025ebxa88d367gx24xv
11 files+250−240/11 viewed
| 1 | 1 | import { Ban, Check, ChevronRight, CircleAlert, Copy, ExternalLink, LoaderCircle, Play, Rocket, SquareTerminal } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | 3 | ||
| 4 | − | import type { AskSource } from "@g1t/contracts"; | |
| 4 | + | import type { AskProposalState, AskSource } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { type Proposal, type Step, foldAnswer, proposalSteps, proposalTitle } from "../lib/ask"; | |
| 7 | 7 | import { Badge } from "./ui/badge"; | |
| ⋯ | |||
| 102 | 102 | * with it (a chat message's `meta.answer.steps`): steps above the answer's | |
| 103 | 103 | * text, sources below. Mount each half where it goes with `part`. | |
| 104 | 104 | */ | |
| 105 | − | export function AskTrail({ events, working, part, slug }: { events: unknown; working: boolean; part: "steps" | "sources" | "proposals"; slug?: string }) { | |
| 106 | − | const { steps, sources, proposals } = foldAnswer(events); | |
| 105 | + | export function AskTrail({ | |
| 106 | + | events, | |
| 107 | + | working, | |
| 108 | + | part, | |
| 109 | + | slug, | |
| 110 | + | message, | |
| 111 | + | }: { | |
| 112 | + | events: unknown; | |
| 113 | + | working: boolean; | |
| 114 | + | part: "steps" | "sources" | "proposals"; | |
| 115 | + | slug?: string; | |
| 116 | + | /** The answer they came in, where what each card did is kept. */ | |
| 117 | + | message?: { id: string; channel_id: string }; | |
| 118 | + | }) { | |
| 119 | + | const { steps, sources, proposals, kept } = foldAnswer(events); | |
| 107 | 120 | if (part === "steps") return <AskSteps steps={steps} working={working} />; | |
| 108 | 121 | if (part === "sources") return <AskSources sources={sources} />; | |
| 109 | 122 | if (!slug || proposals.length === 0) return null; | |
| 110 | 123 | return ( | |
| 111 | 124 | <div className="flex flex-col gap-2"> | |
| 112 | 125 | {proposals.map((proposal) => ( | |
| 113 | − | <SessionProposal key={proposal.id} proposal={proposal} slug={slug} /> | |
| 126 | + | <SessionProposal key={proposal.id} proposal={proposal} slug={slug} message={message} kept={kept[proposal.id]} /> | |
| 114 | 127 | ))} | |
| 115 | 128 | </div> | |
| 116 | 129 | ); | |
| ⋯ | |||
| 134 | 147 | * one is connected), Copy command gives the Claude Code command to run it | |
| 135 | 148 | * with the g1t plugin. Either way the session is tied to the issue. | |
| 136 | 149 | */ | |
| 137 | − | export function SessionProposal({ proposal, slug }: { proposal: Proposal; slug: string }) { | |
| 150 | + | export function SessionProposal({ | |
| 151 | + | proposal, | |
| 152 | + | slug, | |
| 153 | + | message, | |
| 154 | + | kept, | |
| 155 | + | }: { | |
| 156 | + | proposal: Proposal; | |
| 157 | + | slug: string; | |
| 158 | + | message?: { id: string; channel_id: string }; | |
| 159 | + | /** What a click on this card already did, kept on its answer. */ | |
| 160 | + | kept?: AskProposalState; | |
| 161 | + | }) { | |
| 138 | 162 | const [available, setAvailable] = useState<boolean | null>(null); | |
| 139 | 163 | const [busy, setBusy] = useState<"start" | "copy" | null>(null); | |
| 140 | − | const [started, setStarted] = useState<Started | null>(null); | |
| 164 | + | const [started, setStarted] = useState<Started | null>( | |
| 165 | + | kept?.started ? { session_url: kept.session_url ?? null, provider_session_url: kept.provider_session_url ?? null, handoff: false, command: null, prompt: "" } : null, | |
| 166 | + | ); | |
| 141 | 167 | const [copied, setCopied] = useState(false); | |
| 142 | 168 | const [error, setError] = useState<string | null>(null); | |
| 143 | − | const [made, setMade] = useState<Pick<Started, "repo_url" | "production_url" | "deploy_error"> | null>(null); | |
| 169 | + | const [made, setMade] = useState<Pick<Started, "repo_url" | "production_url" | "deploy_error"> | null>( | |
| 170 | + | kept?.repo_url ? { repo_url: kept.repo_url, production_url: kept.production_url ?? null, deploy_error: kept.deploy_error ?? null } : null, | |
| 171 | + | ); | |
| 172 | + | // What another tab, or a click elsewhere, did shows here as it is kept. | |
| 173 | + | useEffect(() => { | |
| 174 | + | if (kept?.repo_url) setMade({ repo_url: kept.repo_url, production_url: kept.production_url ?? null, deploy_error: kept.deploy_error ?? null }); | |
| 175 | + | if (kept?.started) { | |
| 176 | + | setStarted((now) => now ?? { session_url: kept.session_url ?? null, provider_session_url: kept.provider_session_url ?? null, handoff: false, command: null, prompt: "" }); | |
| 177 | + | } | |
| 178 | + | }, [kept?.repo_url, kept?.production_url, kept?.deploy_error, kept?.started, kept?.session_url, kept?.provider_session_url]); | |
| 144 | 179 | ||
| 145 | 180 | useEffect(() => { | |
| 146 | 181 | let live = true; | |
| ⋯ | |||
| 160 | 195 | const response = await fetch(`/${slug}/-/ask`, { | |
| 161 | 196 | method: "POST", | |
| 162 | 197 | headers: { "content-type": "application/json", accept: "application/json" }, | |
| 163 | − | body: JSON.stringify({ ...proposal, handoff }), | |
| 198 | + | body: JSON.stringify({ ...proposal, handoff, message_id: message?.id, channel_id: message?.channel_id }), | |
| 164 | 199 | }); | |
| 165 | 200 | const body = (await response.json().catch(() => null)) as (Started & { error?: string }) | null; | |
| 166 | 201 | // The repository may be made even when the session is not: keep where it is. | |
| 73 | 73 | {message.body} | |
| 74 | 74 | </MessageResponse> | |
| 75 | 75 | )} | |
| 76 | − | {!live && <AskTrail events={answer?.steps} working={false} part="proposals" slug={context.slug} />} | |
| 76 | + | {!live && <AskTrail events={answer?.steps} working={false} part="proposals" slug={context.slug} message={message} />} | |
| 77 | 77 | {!live && <AskTrail events={answer?.steps} working={false} part="sources" />} | |
| 78 | 78 | {!live && message.body && <FolioUnfurls slug={context.slug} body={message.body} />} | |
| 79 | 79 | {actions && !live && ( |
| 50 | 50 | ); | |
| 51 | 51 | assert.equal(folded.sources.length, 1); | |
| 52 | 52 | assert.equal(folded.sources[0]!.url, "https://g1t.sh/acme/web/pull/7"); | |
| 53 | − | assert.deepEqual(foldAnswer("junk"), { steps: [], sources: [], proposals: [] }); | |
| 53 | + | assert.deepEqual(foldAnswer("junk"), { steps: [], sources: [], proposals: [], kept: {} }); | |
| 54 | + | const kept = foldAnswer([{ type: "proposal", id: "p", repo: "a/b", issue: null, title: "x", body: null, mode: "implement", instruction: null }, { type: "proposal_state", id: "p", started: true, session_url: "/s" }]).kept; | |
| 55 | + | assert.deepEqual(kept, { p: { id: "p", started: true, session_url: "/s" } }, "what a card did comes back with its answer"); | |
| 54 | 56 | }); | |
| 55 | 57 | ||
| 56 | 58 | test("a proposed session is kept once and says what it will do", () => { |
| 4 | 4 | * is told the page and reads the issue or pull request on it. Pure, so it | |
| 5 | 5 | * is tested on its own. | |
| 6 | 6 | */ | |
| 7 | − | import type { AskEvent, AskPage, AskSource, AskToolStatus } from "@g1t/contracts"; | |
| 7 | + | import type { AskEvent, AskPage, AskProposalState, AskSource, AskToolStatus } from "@g1t/contracts"; | |
| 8 | 8 | ||
| 9 | 9 | /** First path segments that are g1t's own pages, never a workspace. */ | |
| 10 | 10 | const RESERVED = new Set([ | |
| ⋯ | |||
| 92 | 92 | * An answer's steps and sources from the raw events kept with it (a chat | |
| 93 | 93 | * message keeps the `tool` and `sources` lines it was streamed with): | |
| 94 | 94 | * each step once, as it last was, in the order they started; the last | |
| 95 | − | * `sources`; each proposal once. Anything else, or anything malformed, is skipped. | |
| 95 | + | * `sources`; each proposal once, and what its card did (`kept`, by its id). | |
| 96 | + | * Anything else, or anything malformed, is skipped. | |
| 96 | 97 | */ | |
| 97 | − | export function foldAnswer(events: unknown): { steps: Step[]; sources: AskSource[]; proposals: Proposal[] } { | |
| 98 | + | export function foldAnswer(events: unknown): { steps: Step[]; sources: AskSource[]; proposals: Proposal[]; kept: Record<string, AskProposalState> } { | |
| 98 | 99 | let steps: Step[] = []; | |
| 99 | 100 | let sources: AskSource[] = []; | |
| 100 | 101 | const proposals: Proposal[] = []; | |
| 102 | + | const kept: Record<string, AskProposalState> = {}; | |
| 101 | 103 | for (const event of Array.isArray(events) ? events : []) { | |
| 102 | 104 | if (!event || typeof event !== "object") continue; | |
| 105 | + | // What a proposal's card did, kept on the answer by chat's g1t_proposal. | |
| 106 | + | const state = event as AskProposalState & { type?: string }; | |
| 107 | + | if (state.type === "proposal_state" && typeof state.id === "string") { | |
| 108 | + | const { type: _, ...rest } = state; | |
| 109 | + | kept[state.id] = rest; | |
| 110 | + | continue; | |
| 111 | + | } | |
| 103 | 112 | const line = event as AskEvent; | |
| 104 | 113 | if (line.type === "tool" && typeof line.id === "string" && typeof line.label === "string") steps = withStep(steps, line); | |
| 105 | 114 | if (line.type === "sources" && Array.isArray(line.sources)) { | |
| ⋯ | |||
| 109 | 118 | if (!proposals.some((known) => known.id === line.id)) proposals.push(line); | |
| 110 | 119 | } | |
| 111 | 120 | } | |
| 112 | − | return { steps, sources, proposals }; | |
| 121 | + | return { steps, sources, proposals, kept }; | |
| 113 | 122 | } | |
| 114 | 123 | ||
| 115 | 124 | /** The steps with this one in its place: a new one at the end, a known one updated. */ | |
| 19 | 19 | */ | |
| 20 | 20 | import { data } from "react-router"; | |
| 21 | 21 | ||
| 22 | − | import { AGENT_MODES, type User } from "@g1t/contracts"; | |
| 22 | + | import { AGENT_MODES, type AskProposalState, type User } from "@g1t/contracts"; | |
| 23 | 23 | ||
| 24 | 24 | import type { Route } from "./+types/ask"; | |
| 25 | − | import { agentSessions, ai, deployments, projects, repos } from "../../lib/services.server"; | |
| 25 | + | import { agentSessions, ai, chat, deployments, projects, repos } from "../../lib/services.server"; | |
| 26 | 26 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 27 | 27 | ||
| 28 | 28 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| ⋯ | |||
| 50 | 50 | return Response.json({ error: "That repository isn't in this workspace." }, { status: 400 }); | |
| 51 | 51 | } | |
| 52 | 52 | const text = (key: string) => (typeof input?.[key] === "string" && (input[key] as string).trim()) || undefined; | |
| 53 | − | const issue = Number.isInteger(input?.issue) && (input?.issue as number) > 0 ? (input?.issue as number) : undefined; | |
| 53 | + | const handoff = input?.handoff === true; | |
| 54 | + | // What the card did is kept on the answer that proposed it, step by step, so | |
| 55 | + | // the card shows it wherever the answer is read and another click carries on. | |
| 56 | + | const kept = keeper(user, params.owner, input); | |
| 57 | + | const before = await kept.save({}); | |
| 58 | + | if (before?.started && !handoff) { | |
| 59 | + | return Response.json({ ...before, handoff: false, command: null, prompt: "" }); | |
| 60 | + | } | |
| 61 | + | if (!handoff && before?.starting_at && Date.now() - before.starting_at < STARTING_MS) { | |
| 62 | + | return Response.json({ error: "It's already starting. This card shows it in a moment.", ...before }, { status: 409 }); | |
| 63 | + | } | |
| 64 | + | const asked = Number.isInteger(input?.issue) && (input?.issue as number) > 0 ? (input?.issue as number) : undefined; | |
| 65 | + | // An issue a click already opened is worked on, not opened again. | |
| 66 | + | const issue = asked ?? before?.issue; | |
| 54 | 67 | const title = issue ? undefined : text("title"); | |
| 55 | 68 | if (!issue && !title) return Response.json({ error: "The proposal names no issue." }, { status: 400 }); | |
| 56 | 69 | const mode = AGENT_MODES.find((one) => one.mode === input?.mode)?.mode ?? "implement"; | |
| 70 | + | if (!handoff) await kept.save({ starting_at: Date.now() }); | |
| 57 | 71 | let instruction = text("instruction"); | |
| 58 | 72 | let built: Built | null = null; | |
| 59 | 73 | if (input?.new_repository === true) { | |
| 60 | − | const made = await newRepository(user, namespace, name, { title: title ?? name, isPrivate: input.private === true, deploy: input.deploy !== false }); | |
| 61 | − | if ("error" in made) return Response.json({ error: made.error }, { status: 400 }); | |
| 74 | + | const made = before?.repo_url && before.default_branch | |
| 75 | + | ? { repo_url: before.repo_url, default_branch: before.default_branch, production_url: before.production_url ?? null, deploy_error: before.deploy_error ?? null } | |
| 76 | + | : await newRepository(user, namespace, name, { title: (title ?? text("title")) || name, isPrivate: input.private === true, deploy: input.deploy !== false }); | |
| 77 | + | if ("error" in made) { | |
| 78 | + | await kept.save({ starting_at: 0 }); | |
| 79 | + | return Response.json({ error: made.error }, { status: 400 }); | |
| 80 | + | } | |
| 62 | 81 | built = made; | |
| 82 | + | await kept.save(made); | |
| 63 | 83 | instruction = [firstPush(made), instruction].filter(Boolean).join("\n\n"); | |
| 64 | 84 | } | |
| 65 | 85 | const result = await agentSessions | |
| 66 | − | .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction, mode, handoff: input?.handoff === true }) | |
| 86 | + | .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction, mode, handoff }) | |
| 67 | 87 | .catch(() => null); | |
| 68 | − | if (!result) return Response.json({ error: "The session could not be started. Try again in a moment.", ...built }, { status: 502 }); | |
| 69 | − | if (!result.ok) return Response.json({ error: result.error.message, ...built }, { status: 400 }); | |
| 88 | + | if (!result?.ok) { | |
| 89 | + | if (!handoff) await kept.save({ starting_at: 0 }); | |
| 90 | + | if (!result) return Response.json({ error: "The session could not be started. Try again in a moment.", ...built }, { status: 502 }); | |
| 91 | + | return Response.json({ error: result.error.message, ...built }, { status: 400 }); | |
| 92 | + | } | |
| 70 | 93 | const started = result.value; | |
| 94 | + | const session_url = started.sessionUrl ?? (started.session ? `/${namespace}/${name}/work/sessions/${started.session.id}` : null); | |
| 95 | + | await kept.save( | |
| 96 | + | started.handoff | |
| 97 | + | ? { issue: started.issue } | |
| 98 | + | : { issue: started.issue, started: true, session_url, provider_session_url: started.providerSessionUrl ?? null }, | |
| 99 | + | ); | |
| 71 | 100 | return Response.json({ | |
| 72 | 101 | issue: started.issue, | |
| 73 | − | session_url: started.sessionUrl ?? (started.session ? `/${namespace}/${name}/work/sessions/${started.session.id}` : null), | |
| 102 | + | session_url, | |
| 74 | 103 | provider_session_url: started.providerSessionUrl, | |
| 75 | 104 | handoff: started.handoff, | |
| 76 | 105 | command: started.command, | |
| ⋯ | |||
| 79 | 108 | }); | |
| 80 | 109 | } | |
| 81 | 110 | ||
| 111 | + | /** How long a click that began starting a proposal holds off another. */ | |
| 112 | + | const STARTING_MS = 90_000; | |
| 113 | + | ||
| 114 | + | /** | |
| 115 | + | * Keeps what a proposal's card did on the @g1t answer it came in | |
| 116 | + | * (`message_id` in `channel_id`, the viewer's direct message with g1t), | |
| 117 | + | * through chat's `g1t_proposal`; `save({})` only reads it. Without a | |
| 118 | + | * message, or when chat can't be reached, nothing is kept and null is said: | |
| 119 | + | * the card still works, it just won't remember. | |
| 120 | + | */ | |
| 121 | + | function keeper(user: User, workspace: string, input: Record<string, unknown> | null) { | |
| 122 | + | const id = typeof input?.id === "string" ? input.id : ""; | |
| 123 | + | const channel = typeof input?.channel_id === "string" ? input.channel_id : ""; | |
| 124 | + | const message = typeof input?.message_id === "string" ? input.message_id : ""; | |
| 125 | + | return { | |
| 126 | + | async save(change: Omit<AskProposalState, "id">): Promise<AskProposalState | null> { | |
| 127 | + | if (!id || !channel || !message) return null; | |
| 128 | + | const kept = await chat.g1tProposal(workspace.toLowerCase(), channel, user, message, { ...change, id }).catch((error: unknown) => { | |
| 129 | + | console.error("ask: proposal state not kept", id, error); | |
| 130 | + | return null; | |
| 131 | + | }); | |
| 132 | + | return kept?.ok ? kept.value : null; | |
| 133 | + | }, | |
| 134 | + | }; | |
| 135 | + | } | |
| 136 | + | ||
| 82 | 137 | /** What the card made before the session: the repository, and where it deploys or why it can't. */ | |
| 83 | 138 | type Built = { repo_url: string; default_branch: string; production_url: string | null; deploy_error: string | null }; | |
| 84 | 139 | ||
| 234 | 234 | */ | |
| 235 | 235 | export type ChatAnswerState = "thinking" | "streaming" | "done" | "stopped" | "error"; | |
| 236 | 236 | ||
| 237 | + | /** | |
| 238 | + | * What a proposal's card (Ask's `proposal` step) has done so far, kept on | |
| 239 | + | * the answer as a `proposal_state` step with the same `id`, so the card | |
| 240 | + | * shows it wherever the answer is read and a second click carries on. | |
| 241 | + | */ | |
| 242 | + | export type AskProposalState = { | |
| 243 | + | id: string; | |
| 244 | + | /** When a click began starting it (ms); another click waits on it. */ | |
| 245 | + | starting_at?: number; | |
| 246 | + | /** A new repository's: where it is, its default branch, where it deploys or why it can't. */ | |
| 247 | + | repo_url?: string; | |
| 248 | + | default_branch?: string; | |
| 249 | + | production_url?: string | null; | |
| 250 | + | deploy_error?: string | null; | |
| 251 | + | /** The issue it opened or worked on. */ | |
| 252 | + | issue?: number; | |
| 253 | + | /** The session it started on the workspace's Anthropic account. */ | |
| 254 | + | started?: boolean; | |
| 255 | + | session_url?: string | null; | |
| 256 | + | provider_session_url?: string | null; | |
| 257 | + | }; | |
| 258 | + | ||
| 237 | 259 | export type ChatAnswer = { | |
| 238 | 260 | state: ChatAnswerState; | |
| 239 | 261 | /** The provider's name and the model, once the provider said. */ | |
| ⋯ | |||
| 660 | 682 | /** Stops @g1t's answer still coming; what came stays. */ | |
| 661 | 683 | g1tStop(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; | |
| 662 | 684 | /** | |
| 685 | + | * Keeps what a proposal's card did on the @g1t answer that proposed it | |
| 686 | + | * (merged into its `proposal_state` step), and says what is kept; with | |
| 687 | + | * only `id`, just says. The viewer's own direct message with g1t only. | |
| 688 | + | */ | |
| 689 | + | g1tProposal(workspace: string, channelId: string, viewer: User, messageId: string, state: AskProposalState): Promise<Result<AskProposalState>>; | |
| 690 | + | /** | |
| 663 | 691 | * Clears the viewer's own direct message with @g1t: every message in | |
| 664 | 692 | * it, g1t's answers and their steps, reactions and the notifications | |
| 665 | 693 | * from it are deleted for good, and an answer still coming is thrown | |
| ⋯ | |||
| 763 | 791 | g1tReset: (workspace, channelId, viewer) => call("g1t_reset", { workspace, channel_id: channelId, viewer }), | |
| 764 | 792 | g1tRetry: (workspace, channelId, viewer) => call("g1t_retry", { workspace, channel_id: channelId, viewer }), | |
| 765 | 793 | g1tStop: (workspace, channelId, viewer) => call("g1t_stop", { workspace, channel_id: channelId, viewer }), | |
| 794 | + | g1tProposal: (workspace, channelId, viewer, messageId, state) => | |
| 795 | + | call("g1t_proposal", { workspace, channel_id: channelId, viewer, message_id: messageId, state }), | |
| 766 | 796 | g1tClear: (workspace, channelId, viewer) => call("g1t_clear", { workspace, channel_id: channelId, viewer }), | |
| 767 | 797 | }; | |
| 768 | 798 | } | |
| 18 | 18 | readsNote, | |
| 19 | 19 | shownAnswer, | |
| 20 | 20 | startsAnswer, | |
| 21 | + | withProposalState, | |
| 21 | 22 | type SaidRow, | |
| 22 | 23 | } from "./g1t.ts"; | |
| 23 | 24 | ||
| ⋯ | |||
| 251 | 252 | assert.equal(shownAnswer({ state: "done" }, at, Date.parse(at) + 3_600_000)!.state, "done"); | |
| 252 | 253 | assert.equal(shownAnswer(undefined, at, 0), null); | |
| 253 | 254 | }); | |
| 255 | + | ||
| 256 | + | test("what a proposal's card did is kept on its answer, merged step by step", () => { | |
| 257 | + | const answer = { state: "done" as const, steps: [{ type: "proposal", id: "proposal_1_0", repo: "acme/hello" }] }; | |
| 258 | + | assert.equal(withProposalState(answer, { id: "proposal_9_9" }), null, "no such proposal"); | |
| 259 | + | const made = withProposalState(answer, { id: "proposal_1_0", starting_at: 5, repo_url: "/acme/hello", production_url: "https://hello-acme.g1t.page" })!; | |
| 260 | + | assert.deepEqual(made.state, { id: "proposal_1_0", starting_at: 5, repo_url: "/acme/hello", production_url: "https://hello-acme.g1t.page" }); | |
| 261 | + | assert.equal(made.answer.steps!.length, 2); | |
| 262 | + | // A later step merges into the same one; unknown fields are dropped. | |
| 263 | + | const started = withProposalState(made.answer, { id: "proposal_1_0", issue: 1, started: true, session_url: "/s", evil: "x" } as never)!; | |
| 264 | + | assert.equal(started.answer.steps!.length, 2); | |
| 265 | + | assert.deepEqual(started.answer.steps![1], { | |
| 266 | + | type: "proposal_state", | |
| 267 | + | id: "proposal_1_0", | |
| 268 | + | starting_at: 5, | |
| 269 | + | repo_url: "/acme/hello", | |
| 270 | + | production_url: "https://hello-acme.g1t.page", | |
| 271 | + | issue: 1, | |
| 272 | + | started: true, | |
| 273 | + | session_url: "/s", | |
| 274 | + | }); | |
| 275 | + | // Only the id: says what is kept, changes nothing. | |
| 276 | + | assert.deepEqual(withProposalState(started.answer, { id: "proposal_1_0" })!.state, started.state); | |
| 277 | + | }); | |
| 11 | 11 | * src/index.ts writes the messages. | |
| 12 | 12 | */ | |
| 13 | 13 | // The modules themselves, not the package's index, so the tests load them. | |
| 14 | − | import { G1T_KEY, type ChatAnswer, type MemberProfile } from "@g1t/contracts/chat"; | |
| 14 | + | import { G1T_KEY, type AskProposalState, type ChatAnswer, type MemberProfile } from "@g1t/contracts/chat"; | |
| 15 | 15 | import { ASK_MAX_MESSAGES, ASK_MAX_MESSAGE_CHARS, ASK_MAX_TOTAL_CHARS, type AskMessage, type AskPage } from "@g1t/contracts/ai"; | |
| 16 | 16 | ||
| 17 | 17 | /** How @g1t shows in chat: the g1t mark, never a person. */ | |
| ⋯ | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | /** What a proposal's card has done so far, kept on the answer as a `proposal_state` step. */ | |
| 50 | + | export type ProposalState = AskProposalState; | |
| 51 | + | ||
| 52 | + | const PROPOSAL_TEXT = ["production_url", "deploy_error", "session_url", "provider_session_url"] as const; | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * An answer with what its proposal `id`'s card did merged into it: one | |
| 56 | + | * `proposal_state` step per proposal, made the first time, merged after. | |
| 57 | + | * Only the known fields are kept, texts cut to 500 characters. Null when | |
| 58 | + | * the answer has no such proposal. | |
| 59 | + | */ | |
| 60 | + | export function withProposalState(answer: ChatAnswer, change: ProposalState): { answer: ChatAnswer; state: ProposalState } | null { | |
| 61 | + | const steps = answer.steps ?? []; | |
| 62 | + | if (!steps.some((step) => step.type === "proposal" && step.id === change.id)) return null; | |
| 63 | + | const at = steps.findIndex((step) => step.type === "proposal_state" && step.id === change.id); | |
| 64 | + | const state: ProposalState = { ...(at === -1 ? {} : (steps[at] as unknown as ProposalState)), id: change.id }; | |
| 65 | + | for (const key of PROPOSAL_TEXT) { | |
| 66 | + | const value = change[key]; | |
| 67 | + | if (value === null) state[key] = null; | |
| 68 | + | else if (typeof value === "string") state[key] = value.slice(0, 500); | |
| 69 | + | } | |
| 70 | + | if (typeof change.repo_url === "string") state.repo_url = change.repo_url.slice(0, 500); | |
| 71 | + | if (typeof change.default_branch === "string") state.default_branch = change.default_branch.slice(0, 200); | |
| 72 | + | if (Number.isInteger(change.issue) && change.issue! > 0) state.issue = change.issue; | |
| 73 | + | if (typeof change.starting_at === "number") state.starting_at = change.starting_at; | |
| 74 | + | if (change.started === true) state.started = true; | |
| 75 | + | const step = { type: "proposal_state", ...state } as Record<string, unknown>; | |
| 76 | + | const next = at === -1 ? [...steps, step] : steps.map((kept, index) => (index === at ? step : kept)); | |
| 77 | + | return { answer: { ...answer, steps: next }, state }; | |
| 78 | + | } | |
| 79 | + | ||
| 49 | 80 | export function metaColumn(meta: MessageMeta): string | null { | |
| 50 | 81 | return meta.answer || meta.page || meta.reset ? JSON.stringify(meta) : null; | |
| 51 | 82 | } | |
| 80 | 80 | import { | |
| 81 | 81 | G1T_PROFILE, | |
| 82 | 82 | type MessageMeta, | |
| 83 | + | type ProposalState, | |
| 83 | 84 | type SaidRow, | |
| 84 | 85 | conversation, | |
| 85 | 86 | g1tDmPerson, | |
| ⋯ | |||
| 90 | 91 | parseMeta, | |
| 91 | 92 | shownAnswer, | |
| 92 | 93 | startsAnswer, | |
| 94 | + | withProposalState, | |
| 93 | 95 | } from "./g1t.ts"; | |
| 94 | 96 | import { clearConversation, mayClear } from "./clear.ts"; | |
| 95 | 97 | import { internalAnswer } from "./internal.ts"; | |
| ⋯ | |||
| 1792 | 1794 | return ok(null); | |
| 1793 | 1795 | } | |
| 1794 | 1796 | ||
| 1797 | + | /** | |
| 1798 | + | * Keeps what a proposal's card did (components/ask-steps on the site) on | |
| 1799 | + | * the answer that proposed it, step by step, so the card shows it again | |
| 1800 | + | * wherever the answer is read, and a second click carries on rather than | |
| 1801 | + | * starting over. Only in the viewer's own direct message with g1t. With | |
| 1802 | + | * no change but the id, says what was kept. | |
| 1803 | + | */ | |
| 1804 | + | async g1tProposal(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; state: ProposalState }): Promise<Result<ProposalState>> { | |
| 1805 | + | const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer); | |
| 1806 | + | if (!found.ok) return found; | |
| 1807 | + | const place = found.value; | |
| 1808 | + | const row = await this.messageRow(place.channel.id, String(a.message_id ?? "")); | |
| 1809 | + | if (!row || row.deleted_at || row.author !== G1T_KEY) return fail("not_found", "No such answer from g1t."); | |
| 1810 | + | const meta = parseMeta(row.meta); | |
| 1811 | + | const merged = meta.answer && a.state && typeof a.state.id === "string" ? withProposalState(meta.answer, a.state) : null; | |
| 1812 | + | if (!merged) return fail("not_found", "That answer proposed no such session."); | |
| 1813 | + | const column = metaColumn({ ...meta, answer: merged.answer }); | |
| 1814 | + | await this.db.prepare("UPDATE messages SET meta = ? WHERE id = ?").bind(column, row.id).run(); | |
| 1815 | + | const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, meta: column }]); | |
| 1816 | + | this.broadcast(place.channel.id, { type: "message.updated", message }); | |
| 1817 | + | return ok(merged.state); | |
| 1818 | + | } | |
| 1819 | + | ||
| 1795 | 1820 | async g1tStop(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> { | |
| 1796 | 1821 | const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer); | |
| 1797 | 1822 | if (!found.ok) return found; | |
| ⋯ | |||
| 2264 | 2289 | return Response.json(await service.g1tReset(args)); | |
| 2265 | 2290 | case "g1t_retry": | |
| 2266 | 2291 | return Response.json(await service.g1tRetry(args)); | |
| 2292 | + | case "g1t_proposal": | |
| 2293 | + | return Response.json(await service.g1tProposal(args)); | |
| 2267 | 2294 | case "g1t_stop": | |
| 2268 | 2295 | return Response.json(await service.g1tStop(args)); | |
| 2269 | 2296 | case "g1t_clear": | |
| 319 | 319 | }; | |
| 320 | 320 | match ran { | |
| 321 | 321 | Ok((text, false)) => (text, false, AskToolStatus::Done), | |
| 322 | + | // Nothing there is an answer (a name checked free), not a read that failed. | |
| 323 | + | Ok((text, true)) if ask_tools::is_not_found(&text) => (text, true, AskToolStatus::Done), | |
| 322 | 324 | Ok((text, true)) => (text, true, AskToolStatus::Failed), | |
| 323 | 325 | Err(problem) => (problem, true, AskToolStatus::Failed), | |
| 324 | 326 | } |
| 414 | 414 | arguments["workspace"].as_str().filter(|slug| !slug.is_empty() && !slug.contains('/')).map(|slug| format!("{site}/{slug}")) | |
| 415 | 415 | } | |
| 416 | 416 | ||
| 417 | + | /// Whether a tool's error says only that what it looked for isn't there: | |
| 418 | + | /// a finding, such as a new repository's name being free, not a failed read. | |
| 419 | + | pub fn is_not_found(text: &str) -> bool { | |
| 420 | + | let first = text.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().to_ascii_lowercase(); | |
| 421 | + | first.contains("not found") || first.starts_with("there is no ") || first.starts_with("no such ") | |
| 422 | + | } | |
| 423 | + | ||
| 417 | 424 | /// One line saying what a call found, for the person watching. | |
| 418 | 425 | pub fn summary(text: &str, failed: bool) -> String { | |
| 419 | 426 | if failed { | |
| ⋯ | |||
| 812 | 819 | assert_eq!(summary("{\"title\":\"Fix login\",\"number\":7}", false), "Fix login"); | |
| 813 | 820 | assert_eq!(summary("Not allowed: needs pull_requests:write\nmore", true), "Not allowed: needs pull_requests:write"); | |
| 814 | 821 | assert_eq!(summary("plain", false), "Done"); | |
| 822 | + | assert!(is_not_found("Repository not found.")); | |
| 823 | + | assert!(is_not_found("There is no such project.")); | |
| 824 | + | assert!(!is_not_found("Not allowed: needs pull_requests:write")); | |
| 825 | + | assert!(!is_not_found("g1t answered 500.")); | |
| 815 | 826 | } | |
| 816 | 827 | ||
| 817 | 828 | #[test] | |
Comments
Sign in to comment.