diff --git a/src/components/chat/__tests__/chat-activity.test.tsx b/src/components/chat/__tests__/chat-activity.test.tsx index e070f46f5..1ce9b3a8c 100644 --- a/src/components/chat/__tests__/chat-activity.test.tsx +++ b/src/components/chat/__tests__/chat-activity.test.tsx @@ -39,7 +39,26 @@ describe("ChatActivity", () => { }, ]; + // End-user live mode is now a single status line — no step fraction, no + // rows. The classic Processing bar lives on the debug surface. renderWithProviders(); + expect(screen.getByTestId("chat-activity-live-status")).toBeInTheDocument(); + expect(screen.getByText(/Thinking/)).toBeInTheDocument(); + expect(screen.queryByText("0/5")).not.toBeInTheDocument(); + }); + + it("debug surface keeps the classic processing bar while live", () => { + const events: PipelineEvent[] = [ + { + type: "task_start", + taskType: "ai.labs.rules", + taskId: "1", + index: 0, + timestamp: Date.now(), + }, + ]; + + renderWithProviders(); expect(screen.getByText(/Processing…/)).toBeInTheDocument(); expect(screen.getByText("0/5")).toBeInTheDocument(); }); @@ -116,6 +135,8 @@ describe("ChatActivity", () => { }); it("renders details and supports tool tracing, expansion, and copying details", async () => { + // Details are a resting-state affordance in end-user mode now — live shows + // only the status line. const user = userEvent.setup(); const events: PipelineEvent[] = [ { @@ -140,9 +161,10 @@ describe("ChatActivity", () => { }, ]; - renderWithProviders(); + renderWithProviders(); - // Since isLive is true, it should start expanded + // Auto-expands because the toolTrace makes toolCallCount > 0 — the + // expansion effect, not liveness, drives it expect(screen.getByText("llm")).toBeInTheDocument(); expect(screen.getByText("unknown")).toBeInTheDocument(); @@ -302,3 +324,72 @@ describe("ChatActivity — summary metrics follow the filtered list", () => { expect(screen.getByText(/\b2 steps/)).toBeInTheDocument(); }); }); + +/** + * End-user live mode is one status line: what the agent is doing right now. + * The wall of internal step rows (dozens of identical httpcalls entries, some + * spinning forever when their completion never pairs up) is debug-surface-only. + */ +describe("ChatActivity — end-user live status line", () => { + const start = (taskType: string, index: number): PipelineEvent => ({ + type: "task_start", + taskType, + taskId: String(index), + index, + timestamp: Date.now(), + }); + + it("shows Thinking before any tool call, and never the step rows", () => { + const events: PipelineEvent[] = [ + start("ai.labs.httpcalls", 0), + { ...start("ai.labs.httpcalls", 0), type: "task_complete", durationMs: 1 }, + start("ai.labs.httpcalls", 1), + ]; + + renderWithProviders(); + + expect(screen.getByTestId("chat-activity-live-status")).toBeInTheDocument(); + expect(screen.getByText(/Thinking/)).toBeInTheDocument(); + // No row list, no expander — the httpcalls wall must not render live. + expect(screen.queryByTestId("chat-activity-toggle")).not.toBeInTheDocument(); + expect(screen.queryByText("httpCalls")).not.toBeInTheDocument(); + }); + + it("names the tool currently in use once a toolTrace event arrives", () => { + const events: PipelineEvent[] = [ + start("ai.labs.httpcalls", 0), + { + ...start("ai.labs.httpcalls", 0), + type: "task_complete", + durationMs: 2, + toolTrace: [ + { type: "tool_call", tool: "readAgent", arguments: "{}" }, + { type: "tool_result", tool: "readAgent", result: "ok" }, + ], + }, + start("ai.labs.httpcalls", 1), + ]; + + renderWithProviders(); + + expect(screen.getByText(/readAgent/)).toBeInTheDocument(); + expect(screen.getByText(/1 tool call/)).toBeInTheDocument(); + }); + + it("a failed task breaks through the minimal line to the full view", () => { + const events: PipelineEvent[] = [ + start("ai.labs.langchain", 0), + { + ...start("ai.labs.langchain", 0), + type: "task_failed", + errorType: "timeout", + errorSummary: "provider timed out", + }, + ]; + + renderWithProviders(); + + expect(screen.queryByTestId("chat-activity-live-status")).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-activity-toggle")).toBeInTheDocument(); + }); +}); diff --git a/src/components/chat/__tests__/chat-drawer.test.tsx b/src/components/chat/__tests__/chat-drawer.test.tsx index 5ddaeea33..38badc728 100644 --- a/src/components/chat/__tests__/chat-drawer.test.tsx +++ b/src/components/chat/__tests__/chat-drawer.test.tsx @@ -328,3 +328,48 @@ describe("ChatDrawer — attachments", () => { await waitFor(() => expect(sendBtn).toBeEnabled()); }); }); + +describe("ChatDrawer — opens scrolled to the bottom", () => { + it("an existing transcript mounts at the bottom, not the top", async () => { + // jsdom has no scrollTo, so the hook falls back to scrollTop = scrollHeight + // — give every element a height so that assignment is observable. + const proto = window.HTMLElement.prototype; + const original = Object.getOwnPropertyDescriptor(proto, "scrollHeight"); + Object.defineProperty(proto, "scrollHeight", { configurable: true, get: () => 500 }); + try { + useChatStore.getState().setSelectedAgent("agent-1", "Agent One"); + useChatStore.getState().setConversationId("conv-s1"); + for (let i = 0; i < 5; i++) { + useChatStore.getState().addMessage({ + id: `m-${i}`, + role: i % 2 ? "agent" : "user", + content: `message ${i}`, + timestamp: Date.now(), + }); + } + // Mount CLOSED first: the real drawer stays mounted in the layout and + // only the isOpen dep makes the effect fire on open — rendering with + // isOpen pre-set would pass even with that dep deleted, because a fresh + // mount runs its effect unconditionally. + useChatDrawerStore.setState({ + isOpen: false, + agentId: "agent-1", + agentName: "Agent One", + step: "ready", + errorMessage: null, + }); + + const { container } = renderWithProviders(); + const { act } = await import("@testing-library/react"); + act(() => { + useChatDrawerStore.setState({ isOpen: true }); + }); + const scroller = container.querySelector('[aria-live="polite"]') as HTMLElement; + expect(scroller).toBeTruthy(); + await waitFor(() => expect(scroller.scrollTop).toBe(500)); + } finally { + if (original) Object.defineProperty(proto, "scrollHeight", original); + else delete (proto as unknown as Record).scrollHeight; + } + }); +}); diff --git a/src/components/chat/__tests__/chat-panel.test.tsx b/src/components/chat/__tests__/chat-panel.test.tsx index ed942f992..756dcf09d 100644 --- a/src/components/chat/__tests__/chat-panel.test.tsx +++ b/src/components/chat/__tests__/chat-panel.test.tsx @@ -697,3 +697,22 @@ describe("ChatPanel", () => { }); }); }); + +describe("ChatPanel — shared live status line", () => { + it("shows the operator-style status line once turn events arrive", () => { + useChatStore.getState().setSelectedAgent("agent1", "Test Agent"); + useChatStore.getState().setConversationId("conv1"); + useChatStore.getState().addMessage({ id: "u1", role: "user", content: "hi", timestamp: Date.now() }); + useChatStore.getState().setProcessing(true); + useDebugStore.setState({ + currentTurnEvents: [ + { type: "task_start", taskType: "ai.labs.httpcalls", taskId: "1", index: 0, timestamp: Date.now() }, + ], + }); + + renderWithProviders(); + + expect(screen.getByTestId("chat-activity-live-status")).toBeInTheDocument(); + expect(screen.getByText(/Thinking/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/chat/chat-activity.tsx b/src/components/chat/chat-activity.tsx index 1cafcfa6e..3a5158d6b 100644 --- a/src/components/chat/chat-activity.tsx +++ b/src/components/chat/chat-activity.tsx @@ -204,6 +204,17 @@ export function ChatActivity({ events, isLive, totalSteps, showInternalSteps = f const shouldPulse = isLive && hasRunning; + // The tool the agent is on right now — best live evidence is the last + // tool_call in the most recent event carrying a toolTrace. Null until the + // first tool call: the turn is purely "thinking" until then. + const currentTool = useMemo(() => { + for (let i = events.length - 1; i >= 0; i--) { + const calls = events[i]?.toolTrace?.filter((e) => e.type === "tool_call"); + if (calls?.length) return calls[calls.length - 1]!.tool; + } + return null; + }, [events]); + // Auto-expand if tool calls, cascade steps, or errors are present useEffect(() => { if (toolCallCount > 0 || cascadeSteps.length > 0 || rawTasks.some((t) => t.status === "error")) { @@ -215,6 +226,38 @@ export function ChatActivity({ events, isLive, totalSteps, showInternalSteps = f if (!showInternalSteps && !isLive && tasks.length === 0 && cascadeSteps.length === 0) return null; if (rawTasks.length === 0 && cascadeSteps.length === 0) return null; + // End-user LIVE mode: a scrolling wall of internal step rows (an + // OpenAPI-provisioned agent's pipeline is dozens of identical httpcalls + // rows, and a row whose completion event never pairs up spins forever) says + // nothing a user can act on. Show one honest status line instead — what the + // agent is doing right now — and keep the step list for the debug surface + // and the resting summary. Two things still break through to the full view + // mid-turn, because they ARE actionable: a failed task (its classified + // error must not hide behind a cheerful spinner) and a model-cascade trace + // (escalations are the point of watching one). + const hasErrorTask = rawTasks.some((task) => task.status === "error"); + if (!showInternalSteps && isLive && !hasErrorTask && cascadeSteps.length === 0) { + return ( +
+
+
+ + + {currentTool + ? t("chat.activity.usingTool", "Using {{tool}}…", { tool: currentTool }) + : t("chat.thinking", "Thinking...")} + + {toolCallCount > 0 && ( + + {t("chat.activity.toolCallsCount", "{{count}} tool calls", { count: toolCallCount })} + + )} +
+
+
+ ); + } + return (
s.conversationId); const isProcessing = useChatStore((s) => s.isProcessing); const isThinking = useChatStore((s) => s.isThinking); + const currentTurnEvents = useDebugStore((s) => s.currentTurnEvents); const startConversation = useStartConversation(); - const messagesEndRef = useRef(null); // Attachment staging shared between the drop zone (drawer body) and the // input's picker/paste — one staging area, same as the main panel. @@ -89,10 +93,23 @@ export function ChatDrawer() { (files) => void staging.stageFiles(files), ); - // Auto-scroll to bottom on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + // Smart auto-scroll: follows new content only while the user is AT the + // bottom; scrolled up, the view stays put and a centered arrow offers the + // way back down. + const { + scrollRef, + showScrollFab, + hasNewContent, + scrollToBottom, + handleScroll, + } = useSmartAutoScroll({ + // isOpen: the scroll container mounts only when the drawer opens, so + // without it an existing transcript opens scrolled to the TOP with no + // way down but manual scrolling. currentTurnEvents.length: the live + // status line grows the content without any message change. + deps: [messages, isThinking, isProcessing, isOpen, currentTurnEvents.length], + bottomThreshold: 80, + }); const handleNewConversation = useCallback(() => { if (!agentId) return; @@ -206,7 +223,8 @@ export function ChatDrawer() { {/* Chat messages */} {(showChat || (step === "idle" && conversationId)) && ( <> -
+
+
{messages.length === 0 ? (
@@ -221,17 +239,42 @@ export function ChatDrawer() { {messages.map((msg) => ( ))} - {isThinking && ( + {/* Live status — the same "Thinking…" / "Using {tool}…" + line the operator and main chat show; the plain + indicator covers the gap before the first event. */} + {(isProcessing || isThinking) && currentTurnEvents.length > 0 ? ( + + ) : isThinking ? (
{t("chat.thinking")}
- )} -
+ ) : null}
)}
+ {/* Centered scroll-to-bottom arrow — only while scrolled up */} + {showScrollFab && ( + + )} +
+ {/* Quick replies */} diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx index cd768eb40..bc8909a5a 100644 --- a/src/components/chat/chat-panel.tsx +++ b/src/components/chat/chat-panel.tsx @@ -24,6 +24,7 @@ import { } from "@/hooks/use-attachment-staging"; import { FileDropOverlay, PendingAttachmentChip } from "./attachment-chip"; import { ChatMessage } from "./chat-message"; +import { ChatActivity } from "./chat-activity"; import { ChatHistory } from "./chat-history"; import { StreamingToggle } from "./streaming-toggle"; import { DebugDrawer } from "@/components/debugger/debug-drawer"; @@ -470,10 +471,14 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) { )} - {/* Messages */} + {/* Messages — the FAB must be a SIBLING of the scroller, anchored to + this wrapper: absolutely positioned inside an overflow container it + would scroll away with the content, vanishing exactly when the + user has scrolled up and needs it. */} +
{!selectedAgentId ? ( @@ -499,10 +504,16 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) { ))} - {/* Inline thinking / tool-use indicator — replaces old activity card */} - {(isProcessing || isThinking) && ( - - )} + {/* Live status — the SAME line the operator chat shows: + "Thinking…" / "Using {tool}…" + tool-call count, from the + turn's live events. The dots indicator covers only the gap + before the first event arrives. */} + {(isProcessing || isThinking) && + (currentTurnEvents.length > 0 ? ( + + ) : ( + + ))} {/* Rerun button — shown when last message is an error */} {showRerun && !isProcessing && ( @@ -522,12 +533,13 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) {
)} +
{/* Scroll-to-bottom FAB with new content pulse */} {showScrollFab && ( + )}
{/* Pending attachment chips (staged, uploading, errored) */} diff --git a/src/hooks/__tests__/use-chat-sse-handling.test.tsx b/src/hooks/__tests__/use-chat-sse-handling.test.tsx index faa141753..9665d3435 100644 --- a/src/hooks/__tests__/use-chat-sse-handling.test.tsx +++ b/src/hooks/__tests__/use-chat-sse-handling.test.tsx @@ -70,7 +70,12 @@ describe("use-chat handleSSEEvent — task_failed / error-JSON handling", () => await result.current.mutateAsync({ message: "hi" }); }); - const events = useDebugStore.getState().currentTurnEvents; + // The stream ended without a done frame, so the safety net finalizes the + // debug turn (turn-boundary fix) — the events live in the turn history + // now, not the live set. + const finalized = useDebugStore.getState().turns; + expect(finalized).toHaveLength(1); + const events = finalized[0]!.events; const failed = events.find((e) => e.type === "task_failed"); expect(failed).toBeDefined(); expect(failed?.taskId).toBe("task-42"); @@ -158,3 +163,53 @@ describe("use-chat handleSSEEvent — canonical snapshot text on done", () => { expect(agentMessage?.content).toBe("Structured answer"); }); }); + +describe("use-chat — turn boundary on abnormal stream endings", () => { + beforeEach(() => { + useChatStore.getState().reset(); + useDebugStore.getState().reset(); + h.frames = []; + useChatStore.setState({ + selectedAgentId: "agent1", + conversationId: "conv1", + streamingEnabled: true, + }); + }); + + it("a turn that ends without a done frame does not leak its events into the next turn", async () => { + // Turn 1: a task event arrives, then the stream just closes — no done, no + // error frame (connection drop). Without the boundary fix the events stay + // in currentTurnEvents and the NEXT turn's live status line opens showing + // this turn's tools. + h.frames = [ + { + type: "task_complete", + data: JSON.stringify({ + taskId: "t1", + taskType: "ai.labs.httpcalls", + index: 0, + durationMs: 3, + toolTrace: [{ type: "tool_call", tool: "leakedTool", arguments: "{}" }], + }), + }, + ]; + + const { result } = renderHook(() => useSendMessage(), { wrapper }); + await act(async () => { + await result.current.mutateAsync({ message: "first" }); + }); + + expect(useDebugStore.getState().currentTurnEvents).toHaveLength(0); + + // Turn 2 starts: the live set must be empty before its own events arrive. + h.frames = [{ type: "done", data: JSON.stringify({ conversationState: "READY" }) }]; + await act(async () => { + await result.current.mutateAsync({ message: "second" }); + }); + expect( + useDebugStore.getState().currentTurnEvents.some( + (e) => e.toolTrace?.some((tr) => tr.tool === "leakedTool"), + ), + ).toBe(false); + }); +}); diff --git a/src/hooks/use-chat.ts b/src/hooks/use-chat.ts index bcefd33fc..6802eab9d 100644 --- a/src/hooks/use-chat.ts +++ b/src/hooks/use-chat.ts @@ -431,6 +431,16 @@ export function useSendMessage() { // TanStack Query blocks all subsequent .mutate() calls. const abort = new AbortController(); + // Turn boundary at turn START, not only on the happy endings: a + // previous turn that ended without a done/error frame (connection + // drop, thrown stream error) leaves its events in the debug store, + // and the live status line renders straight off that array — the new + // turn would open showing the previous turn's tools and keep a + // contaminated count throughout. finalizeTurn() moves any leftovers + // into the turn history (preserving the trace) and clears the live + // set; it is a no-op when the previous turn ended cleanly. + useDebugStore.getState().finalizeTurn(); + const events = sendMessageStreaming( "production", selectedAgentId, @@ -459,12 +469,18 @@ export function useSendMessage() { } } // Safety-net: if the stream ended without a done event - // (e.g. connection drop), finalize it here. + // (e.g. connection drop), finalize it here — the debug turn too, so + // the next turn's live status line does not open on this turn's + // events. if (store.getState().isProcessing) { store.getState().finishStreaming(); + useDebugStore.getState().finalizeTurn(); } } else { // --- Non-streaming path — pass context for secret input --- + // Same turn boundary as the streaming path: stale events from an + // abnormally-ended streamed turn must not render over this one. + useDebugStore.getState().finalizeTurn(); // Add a placeholder typing indicator while waiting for the response const typingId = `agent-typing-${Date.now()}`; state.addMessage({ diff --git a/src/hooks/use-smart-auto-scroll.ts b/src/hooks/use-smart-auto-scroll.ts index 8803a5606..b91490271 100644 --- a/src/hooks/use-smart-auto-scroll.ts +++ b/src/hooks/use-smart-auto-scroll.ts @@ -52,10 +52,25 @@ export function useSmartAutoScroll({ } }, [bottomThreshold]); + // The element the tracking state belongs to. The hook can outlive its + // scroll container (the drawer unmounts its children while closed but stays + // mounted itself); without this, closing while scrolled up leaves + // isNearBottomRef=false, and REOPENING lands the fresh container at the TOP + // because the stale ref routes the effect to the new-content branch. + const trackedElRef = useRef(null); + // Trigger auto-scroll on dependency updates if user is near bottom useEffect(() => { const el = scrollRef.current; if (!el) return; + if (trackedElRef.current !== el) { + // A freshly mounted container starts at scrollTop 0 with no user scroll + // history — treat it as at-bottom so the initial jump happens. + trackedElRef.current = el; + isNearBottomRef.current = true; + setShowScrollFab(false); + setHasNewContent(false); + } if (isNearBottomRef.current) { const behavior = isInitialMountRef.current ? "auto" : "smooth"; if (typeof el.scrollTo === "function") { diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index bee5d46b3..ee54a1233 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -726,7 +726,14 @@ "toolCall": "استدعاء أداة", "toolResult": "النتيجة", "error": "حدث خطأ", - "toolCallsCount": "{{count}} من استدعاءات الأدوات" + "toolCallsCount_one": "استدعاء أداة واحد", + "toolCallsCount_zero": "لا استدعاءات أدوات", + "toolCallsCount_two": "استدعاءا أداة", + "toolCallsCount_few": "{{count}} استدعاءات أدوات", + "toolCallsCount_many": "{{count}} استدعاءً للأدوات", + "toolCallsCount_other": "عدد استدعاءات الأدوات: {{count}}", + "toolCallsCount": "عدد استدعاءات الأدوات: {{count}}", + "usingTool": "جارٍ استخدام {{tool}}…" }, "context": { "conversationId": "معرف المحادثة", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6584b0bf7..5f7004f88 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -694,7 +694,10 @@ "toolCall": "Werkzeugaufruf", "toolResult": "Ergebnis", "error": "Fehler aufgetreten", - "toolCallsCount": "{{count}} Tool-Aufrufe" + "toolCallsCount_one": "{{count}} Tool-Aufruf", + "toolCallsCount_other": "{{count}} Tool-Aufrufe", + "toolCallsCount": "{{count}} Tool-Aufrufe", + "usingTool": "Verwendet {{tool}} …" }, "context": { "conversationId": "Gesprächs-ID", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dc391f4c1..24b3f91ec 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -723,7 +723,10 @@ "toolCall": "Tool call", "toolResult": "Result", "error": "Error occurred", - "toolCallsCount": "{{count}} tool calls" + "toolCallsCount_one": "{{count}} tool call", + "toolCallsCount_other": "{{count}} tool calls", + "toolCallsCount": "{{count}} tool calls", + "usingTool": "Using {{tool}}…" }, "context": { "conversationId": "Conversation ID", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3f7fc8486..2f537f131 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -702,7 +702,11 @@ "toolCall": "Llamada a herramienta", "toolResult": "Resultado", "error": "Se produjo un error", - "toolCallsCount": "{{count}} llamadas a herramientas" + "toolCallsCount_one": "{{count}} llamada a herramienta", + "toolCallsCount_many": "{{count}} llamadas a herramientas", + "toolCallsCount_other": "{{count}} llamadas a herramientas", + "toolCallsCount": "{{count}} llamadas a herramientas", + "usingTool": "Usando {{tool}}…" }, "context": { "conversationId": "ID de conversación", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0d00857b6..e7b3acb41 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -702,7 +702,11 @@ "toolCall": "Appel d’outil", "toolResult": "Résultat", "error": "Une erreur s'est produite", - "toolCallsCount": "{{count}} appels d'outils" + "toolCallsCount_one": "{{count}} appel d'outil", + "toolCallsCount_many": "{{count}} appels d'outils", + "toolCallsCount_other": "{{count}} appels d'outils", + "toolCallsCount": "{{count}} appels d'outils", + "usingTool": "Utilisation de {{tool}}…" }, "context": { "conversationId": "ID conversation", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 50258544a..a619fc081 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -694,7 +694,10 @@ "toolCall": "उपकरण कॉल", "toolResult": "परिणाम", "error": "त्रुटि हुई", - "toolCallsCount": "{{count}} टूल कॉल" + "toolCallsCount_one": "{{count}} टूल कॉल", + "toolCallsCount_other": "{{count}} टूल कॉल", + "toolCallsCount": "{{count}} टूल कॉल", + "usingTool": "{{tool}} का उपयोग हो रहा है…" }, "context": { "conversationId": "बातचीत ID", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7ebf67834..94ead18fe 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -694,7 +694,10 @@ "toolCall": "ツール呼び出し", "toolResult": "結果", "error": "エラーが発生しました", - "toolCallsCount": "{{count}} 件のツール呼び出し" + "toolCallsCount_one": "{{count}} 件のツール呼び出し", + "toolCallsCount_other": "{{count}} 件のツール呼び出し", + "toolCallsCount": "{{count}} 件のツール呼び出し", + "usingTool": "{{tool}} を使用中…" }, "context": { "conversationId": "会話ID", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 1da780c33..a3f36f1da 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -694,7 +694,10 @@ "toolCall": "도구 호출", "toolResult": "결과", "error": "오류가 발생했습니다", - "toolCallsCount": "도구 호출 {{count}}건" + "toolCallsCount_one": "도구 호출 {{count}}건", + "toolCallsCount_other": "도구 호출 {{count}}건", + "toolCallsCount": "도구 호출 {{count}}건", + "usingTool": "{{tool}} 사용 중…" }, "context": { "conversationId": "대화 ID", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 6b6ca7460..09c3ef727 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -702,7 +702,11 @@ "toolCall": "Chamada de ferramenta", "toolResult": "Resultado", "error": "Ocorreu um erro", - "toolCallsCount": "{{count}} chamadas de ferramentas" + "toolCallsCount_one": "{{count}} chamada de ferramenta", + "toolCallsCount_many": "{{count}} chamadas de ferramentas", + "toolCallsCount_other": "{{count}} chamadas de ferramentas", + "toolCallsCount": "{{count}} chamadas de ferramentas", + "usingTool": "Usando {{tool}}…" }, "context": { "conversationId": "ID da conversa", diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index af5ebdb75..0bce45a86 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -694,7 +694,10 @@ "toolCall": "การเรียกใช้เครื่องมือ", "toolResult": "ผลลัพธ์", "error": "เกิดข้อผิดพลาด", - "toolCallsCount": "เรียกใช้เครื่องมือ {{count}} ครั้ง" + "toolCallsCount_one": "เรียกใช้เครื่องมือ {{count}} ครั้ง", + "toolCallsCount_other": "เรียกใช้เครื่องมือ {{count}} ครั้ง", + "toolCallsCount": "เรียกใช้เครื่องมือ {{count}} ครั้ง", + "usingTool": "กำลังใช้ {{tool}}…" }, "context": { "conversationId": "รหัสบทสนทนา", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c9d5f6723..e66a66838 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -694,7 +694,10 @@ "toolCall": "工具调用", "toolResult": "结果", "error": "发生错误", - "toolCallsCount": "{{count}} 次工具调用" + "toolCallsCount_one": "{{count}} 次工具调用", + "toolCallsCount_other": "{{count}} 次工具调用", + "toolCallsCount": "{{count}} 次工具调用", + "usingTool": "正在使用 {{tool}}…" }, "context": { "conversationId": "对话 ID", diff --git a/src/lib/operator/__tests__/system-prompt.test.ts b/src/lib/operator/__tests__/system-prompt.test.ts index 5165f1edf..cb12e9032 100644 --- a/src/lib/operator/__tests__/system-prompt.test.ts +++ b/src/lib/operator/__tests__/system-prompt.test.ts @@ -169,6 +169,9 @@ describe("buildOperatorPromptBody", () => { for (const body of [buildOperatorPromptBody(READ_ENDPOINTS), buildOperatorPromptBody(WITH_A_WRITE)]) { expect(body).toContain("help an administrator understand and operate this EDDI"); expect(body).toContain("How to work:"); + // The persona/formatting section — deleting it (or dropping its + // insertion from buildOperatorPromptBody) must fail here. + expect(body).toContain("Personality and formatting:"); } }); diff --git a/src/lib/operator/system-prompt.ts b/src/lib/operator/system-prompt.ts index 947e782d1..3b988016e 100644 --- a/src/lib/operator/system-prompt.ts +++ b/src/lib/operator/system-prompt.ts @@ -259,6 +259,27 @@ const BODY_HOW_TO_WORK = `How to work: - Be brief. An administrator wants the finding, not a narration of your steps. - When something is outside what you can see, say what you would need.`; +/** + * Tone and presentation. The chat surfaces render Markdown (GFM, no raw + * HTML), so the prompt should actively use it — an unformatted wall of status + * output was one of the first things dev-testing flagged. + */ +const BODY_STYLE = `Personality and formatting: +- Be friendly and approachable, and precise underneath it: warm in tone, exact + in content. Plain, confident language — no filler, no exclamation-mark + enthusiasm about routine facts. +- Your answers render as Markdown. For anything beyond a short reply, use it: + headings to separate concerns, bullet lists for enumerations, tables when + comparing agents, versions, or environments, and backticks around ids, + versions, endpoint paths, and config field names. +- Start a longer answer (multiple sections, or any multi-step diagnosis) with a + one- or two-line overview of the finding, then the detail beneath it. +- Use a few emojis as signposts where they genuinely aid scanning — ✅ healthy, + ⚠️ needs attention, ❌ broken or failed, 💡 suggestion. A handful per answer + at most: they are road signs, not decoration. +- Short question, short answer: one sentence needs no headings, no emoji, and + no overview.`; + /** * Appended only when writes are granted. * @@ -276,7 +297,7 @@ const BODY_MAKING_CHANGES = `When you change something: /** Default editable body for a granted endpoint set — the role and style. */ export function buildOperatorPromptBody(endpoints: readonly string[]): string { - const sections = [BODY_ROLE, BODY_ARCHITECTURE, BODY_APP_CONTEXT, BODY_HOW_TO_WORK]; + const sections = [BODY_ROLE, BODY_ARCHITECTURE, BODY_APP_CONTEXT, BODY_STYLE, BODY_HOW_TO_WORK]; if (grantsWriteCapability(endpoints)) sections.push(buildAuthoringSection(endpoints), BODY_MAKING_CHANGES); return sections.join("\n\n"); }