From 2914855b5a38e43bfd4f8a54ba468a016e7ae27c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 11:26:43 -0700 Subject: [PATCH 1/3] feat(web): a bench's worker names itself from its first reply, and you can rename it (CL-9634) --- apps/web/src/bench/bench-pill.tsx | 4 +- apps/web/src/bench/description.css | 10 +- apps/web/src/bench/description.ts | 23 +--- apps/web/src/bench/information-tab.tsx | 35 ++++++ .../web/src/bench/pending-opening-message.tsx | 10 +- apps/web/src/bench/tenant-config.ts | 28 +++++ apps/web/src/bench/worker-name.ts | 111 ++++++++++++++++++ apps/web/src/chat/threads-api.ts | 29 +++-- apps/web/src/chat/workbench-roster.ts | 14 +++ apps/web/src/pages/workbench-page.tsx | 52 +++++--- apps/web/src/pages/worker-page.tsx | 7 +- apps/web/src/pages/workers-page.tsx | 5 +- apps/web/src/shell/workbench-list.tsx | 4 +- apps/web/src/worker-benches.ts | 16 ++- e2e/browser/reply.test.ts | 49 +++++++- 15 files changed, 337 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/bench/tenant-config.ts create mode 100644 apps/web/src/bench/worker-name.ts diff --git a/apps/web/src/bench/bench-pill.tsx b/apps/web/src/bench/bench-pill.tsx index aae462f2d5..fe309c7aa4 100644 --- a/apps/web/src/bench/bench-pill.tsx +++ b/apps/web/src/bench/bench-pill.tsx @@ -7,8 +7,8 @@ function statusLine(status: WorkerStatus, workerName: string | undefined): strin if (status.text === "Working…") { return workerName === undefined ? "Working…" : `${workerName} is working…`; } - if (status.text === "Live") return "Idle"; - return status.text; + const text = status.text === "Live" ? "Idle" : status.text; + return workerName === undefined ? text : `${workerName} · ${text}`; } /** Frosted pill centered over the thread: the bench's worker, its name and diff --git a/apps/web/src/bench/description.css b/apps/web/src/bench/description.css index 435a5302e8..05df3fa65d 100644 --- a/apps/web/src/bench/description.css +++ b/apps/web/src/bench/description.css @@ -8,7 +8,8 @@ flex-direction: column; gap: 8px; } -.bench-description-form textarea { +.bench-description-form textarea, +.bench-description-form input { width: 100%; min-height: 72px; resize: vertical; @@ -30,3 +31,10 @@ font-size: 0.75rem; color: var(--muted-foreground); } +.bench-description-form input { + min-height: 0; +} +.bench-description-form[data-inline] { + flex-direction: row; + align-items: center; +} diff --git a/apps/web/src/bench/description.ts b/apps/web/src/bench/description.ts index fdb8a91354..b26302b40e 100644 --- a/apps/web/src/bench/description.ts +++ b/apps/web/src/bench/description.ts @@ -1,8 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { type } from "arktype"; -import { ApiQueryError } from "@/lib/api-query"; -import { TenantDetailSchema, fetchTenantDetail, type TenantDetail } from "../api"; +import { fetchTenantDetail, type TenantDetail } from "../api"; +import { patchTenantConfigKey } from "./tenant-config"; export const DESCRIPTION_MAX = 280; @@ -24,24 +24,7 @@ export function useBenchDescription(tenantId: string) { queryFn: () => fetchTenantDetail(tenantId), }); const save = useMutation({ - mutationFn: async (text: string) => { - // Read-modify-write on fresh config so other keys are never dropped. - const current = await fetchTenantDetail(tenantId); - const path = `/api/tenants/${encodeURIComponent(tenantId)}`; - const response = await fetch(path, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ config: { ...current.config, "workbench.description": text } }), - }); - if (!response.ok) { - throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); - } - const parsed = TenantDetailSchema(await response.json()); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected tenant response shape: ${parsed.summary}`); - } - return parsed; - }, + mutationFn: (text: string) => patchTenantConfigKey(tenantId, "workbench.description", text), onSuccess: (tenant) => queryClient.setQueryData(descriptionKey(tenantId), tenant), }); return { diff --git a/apps/web/src/bench/information-tab.tsx b/apps/web/src/bench/information-tab.tsx index b719e6f606..bda65bb1a5 100644 --- a/apps/web/src/bench/information-tab.tsx +++ b/apps/web/src/bench/information-tab.tsx @@ -21,6 +21,7 @@ import { benchLink } from "../shell/page-crumbs"; import type { WorkerStatus } from "../worker-status"; import "./description.css"; import { DESCRIPTION_MAX, useBenchDescription } from "./description"; +import { DEFAULT_WORKER_NAME, WORKER_NAME_MAX, useWorkerName } from "./worker-name"; function Section({ title, @@ -79,6 +80,39 @@ function AboutSection({ workbenchTenantId }: { readonly workbenchTenantId: strin ); } +function WorkerNameSection({ workbenchTenantId }: { readonly workbenchTenantId: string }) { + const { name, save } = useWorkerName(workbenchTenantId); + const current = name ?? DEFAULT_WORKER_NAME; + const [draft, setDraft] = useState(null); + const value = draft ?? current; + const dirty = draft !== null && draft.trim() !== current; + return ( +
+
{ + event.preventDefault(); + save.mutate(value.trim(), { + onSuccess: () => setDraft(null), + onError: (cause) => toast(cause instanceof Error ? cause.message : String(cause)), + }); + }} + > + setDraft(event.target.value)} + /> + +
+
+ ); +} + /** Overview of the bench: what it is, what's happening now, what's waiting * on the person, and its live workflows and latest files. */ export function InformationTab({ @@ -118,6 +152,7 @@ export function InformationTab({ return ( <> + {worker !== undefined && status.tone === "working" ? (
diff --git a/apps/web/src/bench/pending-opening-message.tsx b/apps/web/src/bench/pending-opening-message.tsx index 728dfedf68..cf73f1ac30 100644 --- a/apps/web/src/bench/pending-opening-message.tsx +++ b/apps/web/src/bench/pending-opening-message.tsx @@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from "react"; import { sendToWorkbench, type WorkbenchParticipant } from "@/chat/threads-api"; import { workbenchKeys } from "../chat-path"; +import { useSessionUser } from "../navigation"; import { clearOpeningMessage, readOpeningMessage } from "../opening-message"; import "./pending-opening-message.css"; @@ -21,6 +22,7 @@ export function PendingOpeningMessage({ readonly participants: readonly WorkbenchParticipant[]; }) { const queryClient = useQueryClient(); + const sessionUser = useSessionUser(); const [text, setText] = useState(() => readOpeningMessage(workbenchTenantId)); const [timedOut, setTimedOut] = useState(false); const attempted = useRef(false); @@ -31,7 +33,13 @@ export function PendingOpeningMessage({ }, []); const send = useMutation({ - mutationFn: (content: string) => sendToWorkbench({ workbenchTenantId, participants, content }), + mutationFn: (content: string) => + sendToWorkbench({ + workbenchTenantId, + participants, + ...(sessionUser !== undefined ? { senderName: sessionUser.name } : {}), + content, + }), onSuccess: () => { clearOpeningMessage(workbenchTenantId); setText(null); diff --git a/apps/web/src/bench/tenant-config.ts b/apps/web/src/bench/tenant-config.ts new file mode 100644 index 0000000000..46898fbce9 --- /dev/null +++ b/apps/web/src/bench/tenant-config.ts @@ -0,0 +1,28 @@ +import { type } from "arktype"; + +import { ApiQueryError } from "@/lib/api-query"; +import { TenantDetailSchema, fetchTenantDetail, type TenantDetail } from "../api"; + +/** Sets one key of the stock tenant config. Read-modify-write on fresh config + * so other keys are never dropped. */ +export async function patchTenantConfigKey( + tenantId: string, + key: string, + value: string, +): Promise { + const current = await fetchTenantDetail(tenantId); + const path = `/api/tenants/${encodeURIComponent(tenantId)}`; + const response = await fetch(path, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ config: { ...current.config, [key]: value } }), + }); + if (!response.ok) { + throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); + } + const parsed = TenantDetailSchema(await response.json()); + if (parsed instanceof type.errors) { + throw new ApiQueryError(`Unexpected tenant response shape: ${parsed.summary}`); + } + return parsed; +} diff --git a/apps/web/src/bench/worker-name.ts b/apps/web/src/bench/worker-name.ts new file mode 100644 index 0000000000..89714989bd --- /dev/null +++ b/apps/web/src/bench/worker-name.ts @@ -0,0 +1,111 @@ +// The worker's name is bench state in the tenant config, never a redeploy: +// the worker states it on its first reply and the person can rename it. + +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { type } from "arktype"; + +import type { WorkbenchMessage, WorkbenchParticipant } from "@/chat/threads-api"; +import { sameAddress } from "@/chat/threads-api"; +import { fetchTenantDetail, type TenantDetail } from "../api"; +import { patchTenantConfigKey } from "./tenant-config"; + +export const DEFAULT_WORKER_NAME = "New worker"; +export const WORKER_NAME_MAX = 24; +const CONFIG_KEY = "workbench.worker.name"; + +const NameConfig = type({ "workbench.worker.name?": "string" }); + +const NAME_PATTERN = /^[\p{L} -]+$/u; + +/** A name the bench accepts: trimmed, short, letters, spaces and hyphens. */ +export function validWorkerName(raw: string): string | undefined { + const name = raw.trim(); + return name.length > 0 && name.length <= WORKER_NAME_MAX && NAME_PATTERN.test(name) + ? name + : undefined; +} + +function readWorkerName(config: TenantDetail["config"]): string | undefined { + const parsed = NameConfig(config ?? {}); + if (parsed instanceof type.errors) return undefined; + const stored = parsed["workbench.worker.name"]; + return stored === undefined ? undefined : validWorkerName(stored); +} + +const nameKey = (tenantId: string) => ["workbench", tenantId, "worker-name"] as const; + +const nameQuery = (tenantId: string) => ({ + queryKey: nameKey(tenantId), + queryFn: async () => readWorkerName((await fetchTenantDetail(tenantId)).config) ?? null, +}); + +const NAME_LINE = /\n[ \t]*Name:[ \t]*([^\n]+?)[ \t]*$/i; + +/** The name a worker message ends with, when it ends with a valid + * `Name: ` line. */ +export function nameFromMessage(body: string): string | undefined { + const match = NAME_LINE.exec(body.trimEnd()); + return match?.[1] === undefined ? undefined : validWorkerName(match[1]); +} + +/** The message as rendered: a valid trailing `Name:` line is never shown. */ +export function stripNameLine(body: string): string { + const trimmed = body.trimEnd(); + return nameFromMessage(trimmed) === undefined ? body : trimmed.replace(NAME_LINE, ""); +} + +/** The stored name, or undefined until one exists; `save` renames. */ +export function useWorkerName(tenantId: string) { + const queryClient = useQueryClient(); + const query = useQuery(nameQuery(tenantId)); + const save = useMutation({ + mutationFn: (name: string) => setWorkerName(tenantId, name), + onSuccess: (name) => queryClient.setQueryData(nameKey(tenantId), name), + }); + return { + name: query.data ?? undefined, + loaded: query.isSuccess, + save, + }; +} + +export async function setWorkerName(tenantId: string, name: string): Promise { + const valid = validWorkerName(name); + if (valid === undefined) { + throw new Error("Use up to 24 letters, spaces or hyphens."); + } + await patchTenantConfigKey(tenantId, CONFIG_KEY, valid); + return valid; +} + +/** Stored names of many benches, by bench id; a bench with none reads as + * `DEFAULT_WORKER_NAME`. */ +export function useWorkerNames(tenantIds: readonly string[]): ReadonlyMap { + const results = useQueries({ queries: tenantIds.map(nameQuery) }); + return new Map(tenantIds.map((id, i) => [id, results[i]?.data ?? DEFAULT_WORKER_NAME])); +} + +/** Participants with the bench's worker shown under its stored name. */ +export function nameWorkers( + participants: readonly WorkbenchParticipant[], + name: string | undefined, +): readonly WorkbenchParticipant[] { + return participants.map((p) => + p.kind === "agent" ? { ...p, name: name ?? DEFAULT_WORKER_NAME } : p, + ); +} + +/** The first worker message that states a name, for a bench with none. */ +export function firstStatedName( + messages: readonly WorkbenchMessage[], + participants: readonly WorkbenchParticipant[], +): string | undefined { + for (const message of messages) { + if (message.author !== "other") continue; + const sender = participants.find((p) => sameAddress(p.address, message.address)); + if (sender !== undefined && sender.kind !== "agent") continue; + const name = nameFromMessage(message.body); + if (name !== undefined) return name; + } + return undefined; +} diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 514c284a3d..d920e51e56 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -10,7 +10,7 @@ import { listTopLevelRuns } from "../agents-api"; import { WORKER_SOURCE_CONFIG } from "../worker-source"; import { personMailAddress } from "../mail-address"; import { mentionedAgents } from "./mentions"; -import { appendRoster } from "./workbench-roster"; +import { appendRoster, prependFromHeader } from "./workbench-roster"; export class ChatApiError extends Error { constructor( @@ -490,6 +490,8 @@ export async function sendToWorkbench(input: { readonly workbenchTenantId: string; readonly participants: readonly WorkbenchParticipant[]; readonly content: string; + /** The sender's name, put in the `[From: …]` header the worker reads. */ + readonly senderName?: string; /** The turn this reply threads onto — a sub-thread's parent. */ readonly inReplyTo?: string; }): Promise { @@ -506,14 +508,23 @@ export async function sendToWorkbench(input: { const people = input.participants.filter( (participant) => participant.kind === "person" && participant.address.includes("@"), ); - const body = appendRoster(input.content, [ - ...people.map((person) => ({ - name: person.name, - address: person.address, - kind: "person" as const, - })), - ...live.map((agent) => ({ name: agent.name, address: agent.address, kind: "agent" as const })), - ]); + const body = appendRoster( + input.senderName === undefined + ? input.content + : prependFromHeader(input.content, input.senderName), + [ + ...people.map((person) => ({ + name: person.name, + address: person.address, + kind: "person" as const, + })), + ...live.map((agent) => ({ + name: agent.name, + address: agent.address, + kind: "agent" as const, + })), + ], + ); let response: Response; try { response = await fetch(`${mailboxPath(input.workbenchTenantId)}/send`, { diff --git a/apps/web/src/chat/workbench-roster.ts b/apps/web/src/chat/workbench-roster.ts index 2f18d3640d..6d719888df 100644 --- a/apps/web/src/chat/workbench-roster.ts +++ b/apps/web/src/chat/workbench-roster.ts @@ -51,3 +51,17 @@ export function stripRoster(body: string): string { const index = body.lastIndexOf(marker); return index === -1 ? body : body.slice(0, index); } + +const FROM_HEADER = /^\[From: [^\]\n]*\]\n\n/; + +/** Prefixes the message with the `[From: name]` header the worker's prompt + * reads the person's name from. */ +export function prependFromHeader(body: string, senderName: string): string { + return `[From: ${senderName.replace(/[\]\n]/g, " ")}]\n\n${body}`; +} + +/** Removes the header `prependFromHeader` adds, so a person's own message + * never shows it echoed back. */ +export function stripFromHeader(body: string): string { + return body.replace(FROM_HEADER, ""); +} diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index faebfff9dd..b3b6c505b1 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -12,7 +12,7 @@ import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; import { MessageAttachments } from "@/chat/message-attachments"; import { resolveMessagePackage } from "@/chat/deployable-package"; -import { stripRoster } from "@/chat/workbench-roster"; +import { stripFromHeader, stripRoster } from "@/chat/workbench-roster"; import { ancestorChain, listWorkbenchParticipants, @@ -33,6 +33,7 @@ import { ArtifactsTab } from "../bench/artifacts-tab"; import { markTurnPending, useWorkerStatus } from "../worker-status"; import { GrantsTab } from "../bench/grants-tab"; import { useBenchDescription } from "../bench/description"; +import { firstStatedName, nameWorkers, stripNameLine, useWorkerName } from "../bench/worker-name"; import { InformationTab } from "../bench/information-tab"; import { InsightsTab } from "../bench/insights-tab"; import { MembersTab } from "../bench/members-tab"; @@ -44,7 +45,7 @@ import { workbenchKeys } from "../chat-path"; import { tenantKeys } from "../query-client"; import { recordLastWorkbenchId } from "../last-workbench"; import { PROVIDER_SETTINGS_PATH, ProviderSkipBanner } from "../provider-skip-banner"; -import { useNavigate } from "../navigation"; +import { useNavigate, useSessionUser } from "../navigation"; import { isClassifiedInferenceFailureText } from "@/chat/inference-failure"; import { redeployWorkbenchAgent } from "../workbench-create"; import { workbenchIdFromPath } from "../workbench-path"; @@ -92,7 +93,12 @@ function WorkbenchMessageRow({ // The person's own send carries a trailing roster block so agents in the // workbench can hand off to each other; it's never something a person should // see echoed back at them. - const body = message.author === "me" ? stripRoster(message.body) : message.body; + const body = + message.author === "me" + ? stripFromHeader(stripRoster(message.body)) + : resolved?.kind === "agent" + ? stripNameLine(message.body) + : message.body; const { pkg, renderedBody } = resolveMessagePackage(message.attachments, body); const own = message.author === "me"; const time = new Date(message.at); @@ -238,7 +244,24 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } [workbenchTenantId, queryClient], ); - const agents = (participants.data ?? []).filter((participant) => participant.kind === "agent"); + const workerName = useWorkerName(workbenchTenantId); + const roster = nameWorkers(participants.data ?? [], workerName.name); + const sessionUser = useSessionUser(); + const messages = timeline.data ?? []; + // The worker's first reply names it; the stored name wins from then on, and + // a bench that already has one is never overwritten. + const statedName = + workerName.loaded && workerName.name === undefined + ? firstStatedName(messages, roster) + : undefined; + const saveName = workerName.save; + useEffect(() => { + if (statedName !== undefined && !saveName.isPending && !saveName.isSuccess) { + saveName.mutate(statedName); + } + }, [statedName, saveName]); + + const agents = roster.filter((participant) => participant.kind === "agent"); // Released by a hub restart: the asset is still here but nothing is live. const releasedAgents = agents.filter( (agent): agent is WorkbenchParticipant & { assetName: string } => @@ -260,7 +283,8 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } }) => sendToWorkbench({ workbenchTenantId, - participants: participants.data ?? [], + participants: roster, + ...(sessionUser !== undefined ? { senderName: sessionUser.name } : {}), content, ...(inReplyTo !== undefined ? { inReplyTo } : {}), }), @@ -272,7 +296,6 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } onError: () => queryClient.setQueryData(workbenchKeys.pendingTurn(workbenchTenantId), null), }); - const messages = timeline.data ?? []; const openedChain = openThread === null ? [] : ancestorChain(messages, openThread); const opened = openedChain.at(-1); const failure: unknown = timeline.error ?? participants.error; @@ -316,7 +339,7 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } setOpenThread(target.messageId)} @@ -324,7 +347,7 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } ))} {workerStatus.tone === "working" ? : null} @@ -367,7 +390,7 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } @@ -406,22 +429,17 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } workbenchTenantId={workbenchTenantId} worker={agents[0]} status={workerStatus} - participants={participants.data ?? []} + participants={roster} /> ), Artifacts: , Tools: , - Grants: ( - - ), + Grants: , Insights: , Members: ( ), diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx index 08760825d3..9827943bad 100644 --- a/apps/web/src/pages/worker-page.tsx +++ b/apps/web/src/pages/worker-page.tsx @@ -30,7 +30,7 @@ import { useTenantQuery } from "../routines-api"; import { StageTopBar } from "../shell/stage-top-bar"; import { WORKERS_PATH_PREFIX } from "../path-ids"; import { workbenchPath } from "../workbench-path"; -import { useWorkerBenches } from "../worker-benches"; +import { useNamedAgents, useWorkerBenches } from "../worker-benches"; import { StatusPill, WorkerRole, workerStatus } from "./workers-page"; function sourceKey(tenantId: string, agentId: string) { @@ -296,13 +296,14 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) { selectedTenantId !== null, () => listChatAgents(selectedTenantId as string), ); + const named = useNamedAgents(agentsQuery.kind === "ready" ? agentsQuery.data : []); const { byWorker } = useWorkerBenches(); return (
- {(agents) => { - const agent = agents.find((candidate) => candidate.id === agentId); + {() => { + const agent = named.find((candidate) => candidate.id === agentId); if (agent === undefined || selectedTenantId === null) { return ( <> diff --git a/apps/web/src/pages/workers-page.tsx b/apps/web/src/pages/workers-page.tsx index 31786972d7..0319554d72 100644 --- a/apps/web/src/pages/workers-page.tsx +++ b/apps/web/src/pages/workers-page.tsx @@ -14,7 +14,7 @@ import { isAgentNotRunning, listChatAgents, type ChatAgent } from "@/chat/thread import { WorkbenchAvatar } from "@/chat/avatar"; import { describeRestartFailure, redeployWorkbenchAgent } from "../workbench-create"; import { useBench } from "../bench-context"; -import { useWorkerBenches } from "../worker-benches"; +import { useNamedAgents, useWorkerBenches } from "../worker-benches"; import { Link } from "../navigation"; import { useTenantQuery } from "../routines-api"; import { PageLayout } from "../shell/page-layout"; @@ -84,11 +84,12 @@ const ROW_GRID = export function WorkersRosterList({ tenantId, - agents, + agents: listed, }: { readonly tenantId: string; readonly agents: readonly ChatAgent[]; }) { + const agents = useNamedAgents(listed); const { byWorker } = useWorkerBenches(); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index d2b686331b..d5b0f1fe1c 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -9,7 +9,7 @@ import { Hash } from "@/lib/icons"; import { useBench } from "../bench-context"; import { useBenchWorkerStatus } from "../worker-status"; -import { useWorkerBenches } from "../worker-benches"; +import { useNamedAgents, useWorkerBenches } from "../worker-benches"; import { tenantKeys } from "../query-client"; import { workbenchIdFromPath, workbenchPath } from "../workbench-path"; import type { HubTenant } from "../needs-converge"; @@ -150,7 +150,7 @@ function WorkerGroup({ enabled: selectedTenantId !== null, queryFn: () => listChatAgents(selectedTenantId as string), }); - const workers = agents.data ?? []; + const workers = useNamedAgents(agents.data ?? []); if (workers.length === 0) return null; return (
diff --git a/apps/web/src/worker-benches.ts b/apps/web/src/worker-benches.ts index 3d9db96fa2..d3c671d80a 100644 --- a/apps/web/src/worker-benches.ts +++ b/apps/web/src/worker-benches.ts @@ -5,7 +5,8 @@ import { useQueries, useQuery } from "@tanstack/react-query"; -import { listChatAgents } from "@/chat/threads-api"; +import { isDefaultWorker, listChatAgents, type ChatAgent } from "@/chat/threads-api"; +import { DEFAULT_WORKER_NAME, useWorkerNames } from "./bench/worker-name"; import { tenantKeys } from "./query-client"; import { useBench } from "./bench-context"; import type { HubTenant } from "./needs-converge"; @@ -48,3 +49,16 @@ export function useWorkerBenches(): WorkerBenches { return { byWorker, workerIdForAsset: (assetName) => workerIdByAsset.get(assetName), benches }; } + +/** The roster's agents with each bench worker under its bench's stored name + * (the first bench when a worker runs in several); "New worker" until named. */ +export function useNamedAgents(agents: readonly ChatAgent[]): readonly ChatAgent[] { + const { byWorker } = useWorkerBenches(); + const firstBenchIds = agents.map((agent) => byWorker.get(agent.id)?.[0]?.id ?? ""); + const names = useWorkerNames(firstBenchIds.filter((id) => id !== "")); + return agents.map((agent, index) => + isDefaultWorker(agent) + ? { ...agent, name: names.get(firstBenchIds[index] ?? "") ?? DEFAULT_WORKER_NAME } + : agent, + ); +} diff --git a/e2e/browser/reply.test.ts b/e2e/browser/reply.test.ts index 054b8b4621..0633905df7 100644 --- a/e2e/browser/reply.test.ts +++ b/e2e/browser/reply.test.ts @@ -1,6 +1,7 @@ // A fresh user connects an inference provider served by aimock, creates a // bench from /new, and the worker's reply lands in the thread while the -// bench stops showing "working". +// bench stops showing "working". The reply names the worker, which then shows +// everywhere; a rename in the bench drawer survives a reload. import { expect, test } from "bun:test"; import { bootAimock, MOCK_REPLY } from "../lib/aimock"; import { bootBrowserApp, browserGate } from "../lib/browser"; @@ -9,7 +10,12 @@ import { clickText, STEP_TIMEOUT, waitForText } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); describeBrowser("worker reply", () => { - const aimock = bootAimock(); + const aimock = bootAimock([ + { + match: { predicate: () => true }, + response: { content: `${MOCK_REPLY}\n\nName: Ada` }, + }, + ]); const app = bootBrowserApp(); test("the worker's reply lands in the thread", async () => { @@ -46,6 +52,45 @@ describeBrowser("worker reply", () => { timeout: STEP_TIMEOUT, }); expect(aimock().journal().length).toBeGreaterThan(0); + + // The worker named itself: pill, sidebar and message header show it, + // and the marker line is never rendered. + const benchUrl = page.url(); + await page.waitForFunction( + `document.querySelector(".bench-pill")?.innerText.includes("Ada")`, + { + timeout: STEP_TIMEOUT, + }, + ); + await page.waitForFunction( + `document.querySelector("[aria-label='Workbenches and workers']")?.textContent.includes("Ada")`, + { timeout: 20_000 }, + ); + expect(await page.evaluate("document.body.innerText.includes('Name: Ada')")).toBe(false); + + await page.goto(`${app().origin}/workers`, { waitUntil: "networkidle0" }); + await waitForText(page, "Ada"); + + // Rename from the drawer; the new name persists across a reload. + await page.goto(benchUrl, { waitUntil: "networkidle0" }); + await page.click(".bench-pill"); + await page.waitForSelector("input[aria-label='Worker name']"); + await page.evaluate(`document.querySelector("input[aria-label='Worker name']").select()`); + await page.type("input[aria-label='Worker name']", "Bea"); + await clickText(page, "button", "Rename"); + await page.waitForFunction( + `document.querySelector(".bench-pill")?.innerText.includes("Bea ·")`, + { + timeout: STEP_TIMEOUT, + }, + ); + await page.reload({ waitUntil: "networkidle0" }); + await page.waitForFunction( + `document.querySelector(".bench-pill")?.innerText.includes("Bea ·")`, + { + timeout: STEP_TIMEOUT, + }, + ); } catch (cause) { const body = await page.evaluate("document.body.innerText"); process.stderr.write( From 069f94717fde973468c79321514aea814e3d85b3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 11:33:49 -0700 Subject: [PATCH 2/3] fix(web): drop the client-added From header; the worker reads the name from Participants (CL-9634) --- .../web/src/bench/pending-opening-message.tsx | 3 -- apps/web/src/chat/threads-api.ts | 33 ++++++++----------- apps/web/src/chat/workbench-roster.ts | 14 -------- apps/web/src/pages/workbench-page.tsx | 8 ++--- packages/worker/src/system-prompt.ts | 3 +- 5 files changed, 18 insertions(+), 43 deletions(-) diff --git a/apps/web/src/bench/pending-opening-message.tsx b/apps/web/src/bench/pending-opening-message.tsx index cf73f1ac30..b96021f5a5 100644 --- a/apps/web/src/bench/pending-opening-message.tsx +++ b/apps/web/src/bench/pending-opening-message.tsx @@ -8,7 +8,6 @@ import { useEffect, useRef, useState } from "react"; import { sendToWorkbench, type WorkbenchParticipant } from "@/chat/threads-api"; import { workbenchKeys } from "../chat-path"; -import { useSessionUser } from "../navigation"; import { clearOpeningMessage, readOpeningMessage } from "../opening-message"; import "./pending-opening-message.css"; @@ -22,7 +21,6 @@ export function PendingOpeningMessage({ readonly participants: readonly WorkbenchParticipant[]; }) { const queryClient = useQueryClient(); - const sessionUser = useSessionUser(); const [text, setText] = useState(() => readOpeningMessage(workbenchTenantId)); const [timedOut, setTimedOut] = useState(false); const attempted = useRef(false); @@ -37,7 +35,6 @@ export function PendingOpeningMessage({ sendToWorkbench({ workbenchTenantId, participants, - ...(sessionUser !== undefined ? { senderName: sessionUser.name } : {}), content, }), onSuccess: () => { diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index d920e51e56..5206b97cb4 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -10,7 +10,7 @@ import { listTopLevelRuns } from "../agents-api"; import { WORKER_SOURCE_CONFIG } from "../worker-source"; import { personMailAddress } from "../mail-address"; import { mentionedAgents } from "./mentions"; -import { appendRoster, prependFromHeader } from "./workbench-roster"; +import { appendRoster } from "./workbench-roster"; export class ChatApiError extends Error { constructor( @@ -490,8 +490,6 @@ export async function sendToWorkbench(input: { readonly workbenchTenantId: string; readonly participants: readonly WorkbenchParticipant[]; readonly content: string; - /** The sender's name, put in the `[From: …]` header the worker reads. */ - readonly senderName?: string; /** The turn this reply threads onto — a sub-thread's parent. */ readonly inReplyTo?: string; }): Promise { @@ -508,23 +506,18 @@ export async function sendToWorkbench(input: { const people = input.participants.filter( (participant) => participant.kind === "person" && participant.address.includes("@"), ); - const body = appendRoster( - input.senderName === undefined - ? input.content - : prependFromHeader(input.content, input.senderName), - [ - ...people.map((person) => ({ - name: person.name, - address: person.address, - kind: "person" as const, - })), - ...live.map((agent) => ({ - name: agent.name, - address: agent.address, - kind: "agent" as const, - })), - ], - ); + const body = appendRoster(input.content, [ + ...people.map((person) => ({ + name: person.name, + address: person.address, + kind: "person" as const, + })), + ...live.map((agent) => ({ + name: agent.name, + address: agent.address, + kind: "agent" as const, + })), + ]); let response: Response; try { response = await fetch(`${mailboxPath(input.workbenchTenantId)}/send`, { diff --git a/apps/web/src/chat/workbench-roster.ts b/apps/web/src/chat/workbench-roster.ts index 6d719888df..2f18d3640d 100644 --- a/apps/web/src/chat/workbench-roster.ts +++ b/apps/web/src/chat/workbench-roster.ts @@ -51,17 +51,3 @@ export function stripRoster(body: string): string { const index = body.lastIndexOf(marker); return index === -1 ? body : body.slice(0, index); } - -const FROM_HEADER = /^\[From: [^\]\n]*\]\n\n/; - -/** Prefixes the message with the `[From: name]` header the worker's prompt - * reads the person's name from. */ -export function prependFromHeader(body: string, senderName: string): string { - return `[From: ${senderName.replace(/[\]\n]/g, " ")}]\n\n${body}`; -} - -/** Removes the header `prependFromHeader` adds, so a person's own message - * never shows it echoed back. */ -export function stripFromHeader(body: string): string { - return body.replace(FROM_HEADER, ""); -} diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index b3b6c505b1..d540824983 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -12,7 +12,7 @@ import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; import { MessageAttachments } from "@/chat/message-attachments"; import { resolveMessagePackage } from "@/chat/deployable-package"; -import { stripFromHeader, stripRoster } from "@/chat/workbench-roster"; +import { stripRoster } from "@/chat/workbench-roster"; import { ancestorChain, listWorkbenchParticipants, @@ -45,7 +45,7 @@ import { workbenchKeys } from "../chat-path"; import { tenantKeys } from "../query-client"; import { recordLastWorkbenchId } from "../last-workbench"; import { PROVIDER_SETTINGS_PATH, ProviderSkipBanner } from "../provider-skip-banner"; -import { useNavigate, useSessionUser } from "../navigation"; +import { useNavigate } from "../navigation"; import { isClassifiedInferenceFailureText } from "@/chat/inference-failure"; import { redeployWorkbenchAgent } from "../workbench-create"; import { workbenchIdFromPath } from "../workbench-path"; @@ -95,7 +95,7 @@ function WorkbenchMessageRow({ // see echoed back at them. const body = message.author === "me" - ? stripFromHeader(stripRoster(message.body)) + ? stripRoster(message.body) : resolved?.kind === "agent" ? stripNameLine(message.body) : message.body; @@ -246,7 +246,6 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } const workerName = useWorkerName(workbenchTenantId); const roster = nameWorkers(participants.data ?? [], workerName.name); - const sessionUser = useSessionUser(); const messages = timeline.data ?? []; // The worker's first reply names it; the stored name wins from then on, and // a bench that already has one is never overwritten. @@ -284,7 +283,6 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } sendToWorkbench({ workbenchTenantId, participants: roster, - ...(sessionUser !== undefined ? { senderName: sessionUser.name } : {}), content, ...(inReplyTo !== undefined ? { inReplyTo } : {}), }), diff --git a/packages/worker/src/system-prompt.ts b/packages/worker/src/system-prompt.ts index 0042c2c5d3..acad79f735 100644 --- a/packages/worker/src/system-prompt.ts +++ b/packages/worker/src/system-prompt.ts @@ -7,7 +7,8 @@ export const WORKER_SYSTEM_PROMPT = "directly, or coordinate other agents and routines on their behalf.\n" + "\n" + "Your first reply introduces you. Take the person's name from the " + - '"[From: …]" header of their first message and open with one of these, ' + + '"[From: …]" header of their first message when it carries one, else from ' + + 'their entry in the "Participants:" block, and open with one of these, ' + "in your own words:\n" + '(a) "Yo ! Great to meet you, I\'m your new co-worker."\n' + '(b) "Hey , I\'m online and here to run things for you."\n' + From 5250b2e4fb20031cf9be2426fbd262fe1aac814f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 11:55:17 -0700 Subject: [PATCH 3/3] fix(web): the sidebar, roster and worker page list each bench's own workers (CL-9634) --- apps/web/src/bench/members-tab.tsx | 5 +- apps/web/src/pages/worker-page.tsx | 39 +++++++------- apps/web/src/pages/workers-page.tsx | 67 +++++++++-------------- apps/web/src/shell/workbench-list.tsx | 33 ++++-------- apps/web/src/worker-benches.ts | 77 ++++++++++++--------------- 5 files changed, 90 insertions(+), 131 deletions(-) diff --git a/apps/web/src/bench/members-tab.tsx b/apps/web/src/bench/members-tab.tsx index fffec23ee5..61621c8223 100644 --- a/apps/web/src/bench/members-tab.tsx +++ b/apps/web/src/bench/members-tab.tsx @@ -5,7 +5,6 @@ import { IdentityAvatar, WorkbenchAvatar } from "@/chat/avatar"; import type { WorkbenchParticipant } from "@/chat/threads-api"; import { Link } from "../navigation"; import { tenantKeys } from "../query-client"; -import { useWorkerBenches } from "../worker-benches"; import { principalLabel } from "../settings/identity"; import { listPrincipals } from "../settings/tenancy-api"; @@ -27,7 +26,6 @@ export function MembersTab({ queryKey: [...tenantKeys.principals(workbenchTenantId), "members"], queryFn: async () => (await listPrincipals(workbenchTenantId)).filter((p) => p.kind === "user"), }); - const { workerIdForAsset } = useWorkerBenches(); const agents = participants.filter((p) => p.kind === "agent"); return ( @@ -75,8 +73,7 @@ export function MembersTab({ ) : null}
    {agents.map((agent) => { - const workerId = - agent.assetName === undefined ? undefined : workerIdForAsset(agent.assetName); + const workerId = agent.assetName === undefined ? undefined : agent.id; const body = ( <> diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx index 9827943bad..82b6fe7591 100644 --- a/apps/web/src/pages/worker-page.tsx +++ b/apps/web/src/pages/worker-page.tsx @@ -2,16 +2,16 @@ // and the workbench it lives on. import { useState } from "react"; -import { Button, Card, CardDescription, CardTitle, PageShell } from "@corbits/react-ui"; +import { Button, Card, CardDescription, CardTitle, PageShell, Skeleton } from "@corbits/react-ui"; import { toast } from "@corbits/react-ui/ui/toast"; import { reportError } from "@corbits/error-sink"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { GrantEffect } from "@intx/types"; import { WorkbenchAvatar } from "@/chat/avatar"; -import { isDefaultWorker, listChatAgents, type ChatAgent } from "@/chat/threads-api"; +import { isDefaultWorker, type ChatAgent } from "@/chat/threads-api"; import { ChatCircle } from "@/lib/icons"; -import { QueryView, describeApiError } from "@/lib/api-query"; +import { describeApiError } from "@/lib/api-query"; import { listTopLevelRuns } from "../agents-api"; import { updateGrant, type Grant } from "../settings/tenancy-api"; import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy"; @@ -23,14 +23,12 @@ import { toolEffectsToCarry, workerToolGrantsKey, } from "../worker-tool-grants"; -import { useBench } from "../bench-context"; import { Link } from "../navigation"; import { tenantKeys } from "../query-client"; -import { useTenantQuery } from "../routines-api"; import { StageTopBar } from "../shell/stage-top-bar"; import { WORKERS_PATH_PREFIX } from "../path-ids"; import { workbenchPath } from "../workbench-path"; -import { useNamedAgents, useWorkerBenches } from "../worker-benches"; +import { useBenchWorkers } from "../worker-benches"; import { StatusPill, WorkerRole, workerStatus } from "./workers-page"; function sourceKey(tenantId: string, agentId: string) { @@ -290,20 +288,21 @@ function DetailsCard({ } export function WorkerRoute({ agentId }: { readonly agentId: string }) { - const { selectedTenantId } = useBench(); - const agentsQuery = useTenantQuery( - tenantKeys.agents(selectedTenantId ?? "none"), - selectedTenantId !== null, - () => listChatAgents(selectedTenantId as string), - ); - const named = useNamedAgents(agentsQuery.kind === "ready" ? agentsQuery.data : []); - const { byWorker } = useWorkerBenches(); + const { workers, loading, error } = useBenchWorkers(); + const found = workers.find((candidate) => candidate.agent.id === agentId); return (
    - - {() => { - const agent = named.find((candidate) => candidate.id === agentId); + {error !== undefined ? ( +

    + {error} +

    + ) : loading ? ( + + ) : ( + (() => { + const agent = found?.agent; + const selectedTenantId = found?.bench.id ?? null; if (agent === undefined || selectedTenantId === null) { return ( <> @@ -317,7 +316,7 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) { ); } const status = workerStatus(agent); - const agentBenches = byWorker.get(agent.id) ?? []; + const agentBenches = found === undefined ? [] : [found.bench]; const bench = agentBenches[0]; return ( <> @@ -388,8 +387,8 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) {
    ); - }} - + })() + )}
); } diff --git a/apps/web/src/pages/workers-page.tsx b/apps/web/src/pages/workers-page.tsx index 0319554d72..636fbf87a7 100644 --- a/apps/web/src/pages/workers-page.tsx +++ b/apps/web/src/pages/workers-page.tsx @@ -2,21 +2,19 @@ // the sidebar's "+" menu. import { useState } from "react"; -import { Button, RichEmptyState } from "@corbits/react-ui"; +import { Button, RichEmptyState, Skeleton } from "@corbits/react-ui"; import { toast } from "@corbits/react-ui/ui/toast"; import { Plus, Robot } from "@/lib/icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { reportError } from "@corbits/error-sink"; -import { QueryView } from "@/lib/api-query"; import { tenantKeys } from "../query-client"; -import { isAgentNotRunning, listChatAgents, type ChatAgent } from "@/chat/threads-api"; +import { isAgentNotRunning, type ChatAgent } from "@/chat/threads-api"; import { WorkbenchAvatar } from "@/chat/avatar"; import { describeRestartFailure, redeployWorkbenchAgent } from "../workbench-create"; import { useBench } from "../bench-context"; -import { useNamedAgents, useWorkerBenches } from "../worker-benches"; +import { useBenchWorkers, type BenchWorker } from "../worker-benches"; import { Link } from "../navigation"; -import { useTenantQuery } from "../routines-api"; import { PageLayout } from "../shell/page-layout"; import { StageTopBar } from "../shell/stage-top-bar"; import { CreateAgentPanel } from "./create-agent-panel"; @@ -82,31 +80,23 @@ const FILTERS: readonly (readonly [WorkerTone | "all", string])[] = [ const ROW_GRID = "grid items-center gap-4 md:grid-cols-[minmax(200px,1.2fr)_minmax(220px,1.6fr)_minmax(120px,1fr)_72px]"; -export function WorkersRosterList({ - tenantId, - agents: listed, -}: { - readonly tenantId: string; - readonly agents: readonly ChatAgent[]; -}) { - const agents = useNamedAgents(listed); - const { byWorker } = useWorkerBenches(); +export function WorkersRosterList({ workers }: { readonly workers: readonly BenchWorker[] }) { const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); const queryClient = useQueryClient(); const restart = useMutation({ - mutationFn: (agent: ChatAgent) => redeployWorkbenchAgent(tenantId, agent), - onSuccess: () => { + mutationFn: ({ agent, bench }: BenchWorker) => redeployWorkbenchAgent(bench.id, agent), + onSuccess: (_result, { bench }) => { void queryClient.invalidateQueries({ - queryKey: tenantKeys.agents(tenantId), + queryKey: tenantKeys.agents(bench.id), }); }, - onError: (cause) => { - reportError(cause, { operation: "agent_restart", tenantId }); + onError: (cause, { bench }) => { + reportError(cause, { operation: "agent_restart", tenantId: bench.id }); toast(describeRestartFailure(cause)); }, }); - if (agents.length === 0) { + if (workers.length === 0) { return ( } @@ -115,7 +105,7 @@ export function WorkersRosterList({ /> ); } - const rows = agents.map((agent) => ({ agent, status: workerStatus(agent) })); + const rows = workers.map((worker) => ({ ...worker, status: workerStatus(worker.agent) })); const needle = query.trim().toLowerCase(); const shown = rows.filter( ({ agent, status }) => @@ -167,7 +157,7 @@ export function WorkersRosterList({

) : (
    - {shown.map(({ agent, status }) => ( + {shown.map(({ agent, bench, status }) => (
  • {agent.name} @@ -194,20 +184,16 @@ export function WorkersRosterList({ size="sm" className="relative" disabled={restart.isPending} - onClick={() => restart.mutate(agent)} + onClick={() => restart.mutate({ agent, bench })} > - {restart.isPending && restart.variables?.id === agent.id + {restart.isPending && restart.variables?.agent.id === agent.id ? "Starting…" : "Restart"} ) : null} - {(byWorker.get(agent.id) ?? []).map((bench) => ( - - {bench.name} - - ))} + {bench.name} {agent.liveAddress === null ? "" : "Now"} @@ -222,14 +208,7 @@ export function WorkersRosterList({ export function WorkersRoute() { const { selectedTenantId } = useBench(); - const agentsQuery = useTenantQuery( - tenantKeys.agents(selectedTenantId ?? "none"), - selectedTenantId !== null, - () => listChatAgents(selectedTenantId as string), - // Keep polling while any worker is not live, so a restart landing is - // seen without a reload. - (agents) => ((agents?.some((agent) => agent.liveAddress === null) ?? false) ? 3000 : false), - ); + const { workers, loading, error } = useBenchWorkers(); const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); @@ -256,9 +235,15 @@ export function WorkersRoute() { /> ) : ( <> - - {(agents) => } - + {error !== undefined ? ( +

    + {error} +

    + ) : loading ? ( + + ) : ( + + )} void; }) { - // The bench's copy of a workspace worker shares its deploy asset name. - const status = useBenchWorkerStatus(benches, (p) => p.assetName === agent.assetName); + const { agent, bench } = worker; + const status = useBenchWorkerStatus([bench], (p) => p.id === agent.id); return (