From 191bf82c75c8a4d7827b8deaa5cb77744acbd075 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Wed, 4 Feb 2026 12:20:04 -0800 Subject: [PATCH] chore: track agent planning assets and current UI updates --- .agent/PLANS.md | 186 ++++++ .agent/README.md | 17 + .githooks/post-checkout | 23 + .gitignore | 10 +- src/app/page.tsx | 18 +- .../agents/components/AgentChatPanel.tsx | 120 ++-- .../agents/components/AgentSettingsPanel.tsx | 546 ++++-------------- tests/unit/agentChatPanel-controls.test.ts | 67 ++- tests/unit/agentSessionActions.test.ts | 61 ++ tests/unit/agentSettingsPanel.test.ts | 71 ++- 10 files changed, 591 insertions(+), 528 deletions(-) create mode 100644 .agent/PLANS.md create mode 100644 .agent/README.md create mode 100755 .githooks/post-checkout create mode 100644 tests/unit/agentSessionActions.test.ts diff --git a/.agent/PLANS.md b/.agent/PLANS.md new file mode 100644 index 0000000..fc18102 --- /dev/null +++ b/.agent/PLANS.md @@ -0,0 +1,186 @@ +# Codex Execution Plans (ExecPlans): + +This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context. + +## How to use ExecPlans and PLANS.md + +When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research. + +When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously. For each milestone, write failing tests first (when tests are specified), implement until all tests pass, then commit the verified changes before proceeding to the next milestone. If the repo uses Beads, use `br ready` to select work and update issue status as you progress. + +When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work. + +When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation. + +## Requirements + +NON-NEGOTIABLE REQUIREMENTS: + +* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed. +* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained. +* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo. +* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition". +* Every ExecPlan must define every term of art in plain language or do not use it. + +Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe. + +The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan. + +## Formatting + +Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists. + +When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks. + +Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first. + +## Guidelines + +Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself. + +Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details. + +Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to [http://localhost:8080/health](http://localhost:8080/health) returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior). + +Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable. + +Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go. + +Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project’s toolchain and how to interpret their results. + +When specifying tests, prefer a test-first approach: describe which tests to write and what they should assert before describing the implementation. This allows the implementing agent to write failing tests first, then implement until the tests pass. Specify the test file paths, test function names, and the exact assertions expected. If the project has an existing test structure, follow its conventions. + +Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs. + +## Milestones + +Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation. + +Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan. + +## Verification and Test-Driven Milestones + +Every milestone must include built-in verification steps that allow the implementing agent to confirm correctness without human intervention. Prefer test-driven development: write failing tests that define the milestone's acceptance criteria before writing the implementation. The milestone is not complete until all tests pass. + +When designing a milestone, follow this verification pattern: + +1. Define the acceptance criteria as concrete, observable behaviors. +2. Write tests (unit, integration, or end-to-end as appropriate) that exercise these behaviors. Run them to confirm they fail for the expected reasons. +3. Implement the feature or change. +4. Run the tests again. The milestone is complete only when all tests pass and any other validation steps succeed. +5. After all tests pass, the implementing agent is permitted (and encouraged) to commit the changes with a clear commit message describing the milestone completed. + +If tests are not feasible for a particular milestone (e.g., infrastructure setup, configuration changes, or exploratory prototypes), specify alternative verification steps: commands to run, outputs to observe, or states to confirm. The key requirement is that the agent can autonomously verify success without asking for human confirmation. + +Commits should be frequent and atomic. Each milestone that passes verification should be committed before proceeding to the next. This creates a clean history of incremental progress and allows safe rollback if later milestones encounter issues. The commit message should reference the milestone and summarize what was achieved. + +## Issue Tracking with Beads + +ExecPlans integrate with Beads (`br`) for local issue tracking. When a repo has Beads initialized (`.beads/` directory exists), use it to track milestones as issues. + +When authoring an ExecPlan, create a Beads issue for each milestone: `br create "Milestone N: " --type task --priority <0-4> --description "<scope and acceptance criteria>"`. Use `br dep add <child> <parent>` to express milestone dependencies. Record the issue IDs in the Progress section. + +When implementing, use `br ready --json` to select the next unblocked milestone. Claim it with `br update <id> --status in_progress`. After verification passes, close it with `br close <id> --reason "Tests pass, committed"`. Run `br sync --flush-only` before committing to include the issue state in git history. + +If Beads is not initialized or the user has not requested issue tracking, skip these steps. + +## Living plans and design decisions + +* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section. +* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional. +* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal). +* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you. +* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned. + +# Prototyping milestones and parallel implementations + +It is acceptable—-and often encouraged—-to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as “prototyping”; describe how to run and observe results; and state the criteria for promoting or discarding the prototype. + +Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation. + +## Skeleton of a Good ExecPlan + + # <Short, action-oriented description> + + This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + + If PLANS.md file is checked into the repo, reference the path to that file here from the repository root and note that this document must be maintained in accordance with PLANS.md. + + ## Purpose / Big Picture + + Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable. + + ## Progress + + Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work. + + - [x] (2025-10-01 13:00Z) Example completed step. [BEAD-001] + - [ ] Example incomplete step. [BEAD-002] + - [ ] Example partially completed step (completed: X; remaining: Y). [BEAD-003] + + Use timestamps to measure rates of progress. If using Beads, include the issue ID in brackets after each step. + + ## Surprises & Discoveries + + Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence. + + - Observation: … + Evidence: … + + ## Decision Log + + Record every decision made while working on the plan in the format: + + - Decision: … + Rationale: … + Date/Author: … + + ## Outcomes & Retrospective + + Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose. + + ## Context and Orientation + + Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans. + + ## Plan of Work + + Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal. + + ## Concrete Steps + + State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds. + + ## Validation and Acceptance + + Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run <project’s test command> and expect <N> passed; the new test <name> fails before the change and passes after>". + + For each milestone, specify the verification workflow: + 1. Tests to write: List the test file paths, test function names, and assertions. These tests should be written first and must fail before implementation. + 2. Implementation: Describe the changes to make. + 3. Verification: Run the tests. The milestone is complete only when all tests pass. + 4. Commit: After verification passes, commit the changes with a message referencing the milestone. + + Example: "Write test_user_creation in tests/test_users.py that asserts a 201 response with user ID. Run pytest tests/test_users.py -k test_user_creation and confirm it fails. Implement the endpoint. Run the test again and confirm it passes. Commit with message 'Milestone 1: Add user creation endpoint'." + + ## Idempotence and Recovery + + If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion. + + ## Artifacts and Notes + + Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success. + + ## Interfaces and Dependencies + + Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.: + + In crates/foo/planner.rs, define: + + pub trait Planner { + fn plan(&self, observed: &Observed) -> Vec<Action>; + } + +If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED. + +When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything. \ No newline at end of file diff --git a/.agent/README.md b/.agent/README.md new file mode 100644 index 0000000..1222c12 --- /dev/null +++ b/.agent/README.md @@ -0,0 +1,17 @@ +# Agent Workspace Notes + +This directory intentionally mixes tracked and local-only files. + +Tracked in git: +- `PLANS.md` +- `done/` + +Local-only: +- `execplan-pending.md` +- `local/` (symlink to machine-private notes) + +The `post-checkout` git hook creates `local/` as a symlink to: +- `${OPENCLAW_STUDIO_PRIVATE_AGENT_DIR}` if set +- otherwise `~/.codex/private/openclaw-studio` + +Store EC2 credentials, host-specific notes, and other sensitive material under that private path. diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100755 index 0000000..a2d6c44 --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || exit 0)" +agent_dir="$repo_root/.agent" +private_dir="${OPENCLAW_STUDIO_PRIVATE_AGENT_DIR:-$HOME/.codex/private/openclaw-studio}" +local_link="$agent_dir/local" + +mkdir -p "$agent_dir" +mkdir -p "$private_dir" + +if [ -e "$local_link" ] && [ ! -L "$local_link" ]; then + exit 0 +fi + +if [ -L "$local_link" ]; then + target="$(readlink "$local_link")" + if [ "$target" = "$private_dir" ]; then + exit 0 + fi +fi + +ln -sfn "$private_dir" "$local_link" diff --git a/.gitignore b/.gitignore index 62348b7..41d34ed 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,15 @@ test-results /output/playwright # agent state -/.agent +/.agent/* +!/.agent/PLANS.md +!/.agent/README.md +!/.agent/done/ +!/.agent/done/** +/.agent/local +/.agent/execplan-pending.md +/.agent/*.local.md +/.agent/future-plans /.openclaw /.clawdbot /.moltbot diff --git a/src/app/page.tsx b/src/app/page.tsx index 8b2b985..957a6e6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1604,9 +1604,13 @@ const AgentStudioPage = () => { agent={focusedAgent} isSelected={false} canSend={status === "connected"} + models={gatewayModels} onOpenSettings={() => handleOpenAgentSettings(focusedAgent.agentId)} - onNameChange={(name) => - handleRenameAgent(focusedAgent.agentId, name) + onModelChange={(value) => + handleModelChange(focusedAgent.agentId, focusedAgent.sessionKey, value) + } + onThinkingChange={(value) => + handleThinkingChange(focusedAgent.agentId, focusedAgent.sessionKey, value) } onDraftChange={(value) => handleDraftChange(focusedAgent.agentId, value) @@ -1649,19 +1653,13 @@ const AgentStudioPage = () => { <AgentSettingsPanel key={settingsAgent.agentId} agent={settingsAgent} - client={client} - models={gatewayModels} onClose={() => { setSettingsAgentId(null); setMobilePane("chat"); }} + onRename={(name) => handleRenameAgent(settingsAgent.agentId, name)} + onNewSession={() => {}} onDelete={() => handleDeleteAgent(settingsAgent.agentId)} - onModelChange={(value) => - handleModelChange(settingsAgent.agentId, settingsAgent.sessionKey, value) - } - onThinkingChange={(value) => - handleThinkingChange(settingsAgent.agentId, settingsAgent.sessionKey, value) - } onToolCallingToggle={(enabled) => handleToolCallingToggle(settingsAgent.agentId, enabled) } diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx index 741c2b7..c2b805b 100644 --- a/src/features/agents/components/AgentChatPanel.tsx +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentState as AgentRecord } from "@/features/agents/state/store"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { normalizeAgentName } from "@/lib/names/agentNames"; import { Cog, Shuffle } from "lucide-react"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; import { AgentAvatar } from "./AgentAvatar"; import { buildAgentChatItems, summarizeToolLabel } from "./chatItems"; import { EmptyStatePanel } from "./EmptyStatePanel"; @@ -12,8 +12,10 @@ type AgentChatPanelProps = { agent: AgentRecord; isSelected: boolean; canSend: boolean; + models: GatewayModelChoice[]; onOpenSettings: () => void; - onNameChange: (name: string) => Promise<boolean>; + onModelChange: (value: string | null) => void; + onThinkingChange: (value: string | null) => void; onDraftChange: (value: string) => void; onSend: (message: string) => void; onAvatarShuffle: () => void; @@ -23,13 +25,14 @@ export const AgentChatPanel = ({ agent, isSelected, canSend, + models, onOpenSettings, - onNameChange, + onModelChange, + onThinkingChange, onDraftChange, onSend, onAvatarShuffle, }: AgentChatPanelProps) => { - const [nameDraft, setNameDraft] = useState(agent.name); const [draftValue, setDraftValue] = useState(agent.draft); const draftRef = useRef<HTMLTextAreaElement | null>(null); const chatRef = useRef<HTMLDivElement | null>(null); @@ -47,11 +50,6 @@ export const AgentChatPanel = ({ draftRef.current = el instanceof HTMLTextAreaElement ? el : null; }, []); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setNameDraft(agent.name); - }, [agent.name]); - useEffect(() => { if (agent.draft === plainDraftRef.current) return; plainDraftRef.current = agent.draft; @@ -63,23 +61,6 @@ export const AgentChatPanel = ({ resizeDraft(); }, [resizeDraft, agent.draft]); - const commitName = async () => { - const next = normalizeAgentName(nameDraft); - if (!next) { - setNameDraft(agent.name); - return; - } - if (next === agent.name) { - return; - } - const ok = await onNameChange(next); - if (!ok) { - setNameDraft(agent.name); - return; - } - setNameDraft(next); - }; - const statusColor = agent.status === "running" ? "border border-primary/30 bg-primary/15 text-foreground" @@ -111,6 +92,26 @@ export const AgentChatPanel = ({ ] ); + const modelOptions = useMemo( + () => + models.map((entry) => ({ + value: `${entry.provider}/${entry.id}`, + label: + entry.name === `${entry.provider}/${entry.id}` + ? entry.name + : `${entry.name} (${entry.provider}/${entry.id})`, + reasoning: entry.reasoning, + })), + [models] + ); + const modelValue = agent.model ?? ""; + const modelOptionsWithFallback = + modelValue && !modelOptions.some((option) => option.value === modelValue) + ? [{ value: modelValue, label: modelValue, reasoning: undefined }, ...modelOptions] + : modelOptions; + const selectedModel = modelOptionsWithFallback.find((option) => option.value === modelValue); + const allowThinking = selectedModel?.reasoning !== false; + const avatarSeed = agent.avatarSeed ?? agent.agentId; return ( <div data-agent-panel className="group fade-up relative flex h-full w-full flex-col"> @@ -145,23 +146,9 @@ export const AgentChatPanel = ({ isSelected ? "agent-name-selected" : "border-border" }`} > - <input - className="w-full bg-transparent text-center text-xs font-semibold uppercase tracking-[0.16em] text-foreground outline-none" - value={nameDraft} - onChange={(event) => setNameDraft(event.target.value)} - onBlur={() => { - void commitName(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.currentTarget.blur(); - } - if (event.key === "Escape") { - setNameDraft(agent.name); - event.currentTarget.blur(); - } - }} - /> + <div className="w-full text-center text-xs font-semibold uppercase tracking-[0.16em] text-foreground"> + {agent.name} + </div> </div> <div className="flex flex-wrap items-center gap-2"> <span @@ -180,6 +167,53 @@ export const AgentChatPanel = ({ <Cog className="h-4 w-4" /> </button> </div> + <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_128px]"> + <label className="flex min-w-0 flex-col gap-1 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Model</span> + <select + className="h-8 w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap rounded-md border border-border bg-card/75 px-2 text-[11px] font-semibold text-foreground" + aria-label="Model" + value={modelValue} + onChange={(event) => { + const value = event.target.value.trim(); + onModelChange(value ? value : null); + }} + > + {modelOptionsWithFallback.length === 0 ? ( + <option value="">No models found</option> + ) : null} + {modelOptionsWithFallback.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </label> + {allowThinking ? ( + <label className="flex flex-col gap-1 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Thinking</span> + <select + className="h-8 rounded-md border border-border bg-card/75 px-2 text-[11px] font-semibold text-foreground" + aria-label="Thinking" + value={agent.thinkingLevel ?? ""} + onChange={(event) => { + const value = event.target.value.trim(); + onThinkingChange(value ? value : null); + }} + > + <option value="">Default</option> + <option value="off">Off</option> + <option value="minimal">Minimal</option> + <option value="low">Low</option> + <option value="medium">Medium</option> + <option value="high">High</option> + <option value="xhigh">XHigh</option> + </select> + </label> + ) : ( + <div /> + )} + </div> </div> </div> </div> diff --git a/src/features/agents/components/AgentSettingsPanel.tsx b/src/features/agents/components/AgentSettingsPanel.tsx index 90d6ce0..515c8c0 100644 --- a/src/features/agents/components/AgentSettingsPanel.tsx +++ b/src/features/agents/components/AgentSettingsPanel.tsx @@ -1,228 +1,69 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import type { AgentState } from "@/features/agents/state/store"; -import type { GatewayClient } from "@/lib/gateway/GatewayClient"; -import { - resolveHeartbeatSettings, - updateGatewayHeartbeat, - type GatewayConfigSnapshot, -} from "@/lib/gateway/agentConfig"; -import type { GatewayModelChoice } from "@/lib/gateway/models"; - -const HEARTBEAT_INTERVAL_OPTIONS = ["15m", "30m", "1h", "2h", "6h", "12h", "24h"]; type AgentSettingsPanelProps = { agent: AgentState; - client: GatewayClient; - models: GatewayModelChoice[]; onClose: () => void; + onRename: (value: string) => Promise<boolean>; + onNewSession: () => Promise<void> | void; onDelete: () => void; - onModelChange: (value: string | null) => void; - onThinkingChange: (value: string | null) => void; onToolCallingToggle: (enabled: boolean) => void; onThinkingTracesToggle: (enabled: boolean) => void; }; export const AgentSettingsPanel = ({ agent, - client, - models, onClose, + onRename, + onNewSession, onDelete, - onModelChange, - onThinkingChange, onToolCallingToggle, onThinkingTracesToggle, }: AgentSettingsPanelProps) => { - const [heartbeatLoading, setHeartbeatLoading] = useState(false); - const [heartbeatSaving, setHeartbeatSaving] = useState(false); - const [heartbeatDirty, setHeartbeatDirty] = useState(false); - const [heartbeatError, setHeartbeatError] = useState<string | null>(null); - const [heartbeatOverride, setHeartbeatOverride] = useState(false); - const [heartbeatEnabled, setHeartbeatEnabled] = useState(true); - const [heartbeatEvery, setHeartbeatEvery] = useState("30m"); - const [heartbeatIntervalMode, setHeartbeatIntervalMode] = useState<"preset" | "custom">( - "preset" - ); - const [heartbeatCustomMinutes, setHeartbeatCustomMinutes] = useState("45"); - const [heartbeatTargetMode, setHeartbeatTargetMode] = useState<"last" | "none" | "custom">( - "last" - ); - const [heartbeatTargetCustom, setHeartbeatTargetCustom] = useState(""); - const [heartbeatIncludeReasoning, setHeartbeatIncludeReasoning] = useState(false); - const [heartbeatActiveHoursEnabled, setHeartbeatActiveHoursEnabled] = useState(false); - const [heartbeatActiveStart, setHeartbeatActiveStart] = useState("08:00"); - const [heartbeatActiveEnd, setHeartbeatActiveEnd] = useState("18:00"); - const [heartbeatAckMaxChars, setHeartbeatAckMaxChars] = useState("300"); - - const loadHeartbeat = useCallback(async () => { - setHeartbeatLoading(true); - setHeartbeatError(null); - try { - const snapshot = await client.call<GatewayConfigSnapshot>("config.get", {}); - const config = - snapshot.config && typeof snapshot.config === "object" ? snapshot.config : {}; - const result = resolveHeartbeatSettings(config, agent.agentId); - const every = result.heartbeat.every ?? "30m"; - const enabled = every !== "0m"; - const isPreset = HEARTBEAT_INTERVAL_OPTIONS.includes(every); - if (isPreset) { - setHeartbeatIntervalMode("preset"); - } else { - setHeartbeatIntervalMode("custom"); - const parsed = - every.endsWith("m") - ? Number.parseInt(every, 10) - : every.endsWith("h") - ? Number.parseInt(every, 10) * 60 - : Number.parseInt(every, 10); - if (Number.isFinite(parsed) && parsed > 0) { - setHeartbeatCustomMinutes(String(parsed)); - } - } - const target = result.heartbeat.target ?? "last"; - const targetMode = target === "last" || target === "none" ? target : "custom"; - setHeartbeatOverride(result.hasOverride); - setHeartbeatEnabled(enabled); - setHeartbeatEvery(enabled ? every : "30m"); - setHeartbeatTargetMode(targetMode); - setHeartbeatTargetCustom(targetMode === "custom" ? target : ""); - setHeartbeatIncludeReasoning(Boolean(result.heartbeat.includeReasoning)); - if (result.heartbeat.activeHours) { - setHeartbeatActiveHoursEnabled(true); - setHeartbeatActiveStart(result.heartbeat.activeHours.start); - setHeartbeatActiveEnd(result.heartbeat.activeHours.end); - } else { - setHeartbeatActiveHoursEnabled(false); - } - if (typeof result.heartbeat.ackMaxChars === "number") { - setHeartbeatAckMaxChars(String(result.heartbeat.ackMaxChars)); - } else { - setHeartbeatAckMaxChars("300"); - } - setHeartbeatDirty(false); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to load heartbeat settings."; - setHeartbeatError(message); - } finally { - setHeartbeatLoading(false); - } - }, [client, agent.agentId]); - - const saveHeartbeat = useCallback(async () => { - setHeartbeatSaving(true); - setHeartbeatError(null); - try { - const target = - heartbeatTargetMode === "custom" ? heartbeatTargetCustom.trim() : heartbeatTargetMode; - let every = heartbeatEnabled ? heartbeatEvery.trim() : "0m"; - if (heartbeatEnabled && heartbeatIntervalMode === "custom") { - const customValue = Number.parseInt(heartbeatCustomMinutes, 10); - if (!Number.isFinite(customValue) || customValue <= 0) { - setHeartbeatError("Custom interval must be a positive number."); - setHeartbeatSaving(false); - return; - } - every = `${customValue}m`; - } - const ackParsed = Number.parseInt(heartbeatAckMaxChars, 10); - const ackMaxChars = Number.isFinite(ackParsed) ? ackParsed : 300; - const activeHours = - heartbeatActiveHoursEnabled && heartbeatActiveStart && heartbeatActiveEnd - ? { start: heartbeatActiveStart, end: heartbeatActiveEnd } - : null; - const result = await updateGatewayHeartbeat({ - client, - agentId: agent.agentId, - sessionKey: agent.sessionKey, - payload: { - override: heartbeatOverride, - heartbeat: { - every, - target: target || "last", - includeReasoning: heartbeatIncludeReasoning, - ackMaxChars, - activeHours, - }, - }, - }); - setHeartbeatOverride(result.hasOverride); - setHeartbeatEnabled(result.heartbeat.every !== "0m"); - setHeartbeatEvery(result.heartbeat.every); - setHeartbeatTargetMode( - result.heartbeat.target === "last" || result.heartbeat.target === "none" - ? result.heartbeat.target - : "custom" - ); - setHeartbeatTargetCustom( - result.heartbeat.target === "last" || result.heartbeat.target === "none" - ? "" - : result.heartbeat.target - ); - setHeartbeatIncludeReasoning(result.heartbeat.includeReasoning); - if (result.heartbeat.activeHours) { - setHeartbeatActiveHoursEnabled(true); - setHeartbeatActiveStart(result.heartbeat.activeHours.start); - setHeartbeatActiveEnd(result.heartbeat.activeHours.end); - } else { - setHeartbeatActiveHoursEnabled(false); - } - if (typeof result.heartbeat.ackMaxChars === "number") { - setHeartbeatAckMaxChars(String(result.heartbeat.ackMaxChars)); - } else { - setHeartbeatAckMaxChars("300"); - } - setHeartbeatDirty(false); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to save heartbeat settings."; - setHeartbeatError(message); - } finally { - setHeartbeatSaving(false); - } - }, [ - heartbeatActiveEnd, - heartbeatActiveHoursEnabled, - heartbeatActiveStart, - heartbeatAckMaxChars, - heartbeatCustomMinutes, - heartbeatEnabled, - heartbeatEvery, - heartbeatIncludeReasoning, - heartbeatIntervalMode, - heartbeatOverride, - heartbeatTargetCustom, - heartbeatTargetMode, - client, - agent.agentId, - agent.sessionKey, - ]); + const [nameDraft, setNameDraft] = useState(agent.name); + const [renameSaving, setRenameSaving] = useState(false); + const [renameError, setRenameError] = useState<string | null>(null); + const [sessionBusy, setSessionBusy] = useState(false); useEffect(() => { - void loadHeartbeat(); - }, [loadHeartbeat]); + setNameDraft(agent.name); + setRenameError(null); + }, [agent.agentId, agent.name]); - const modelOptions = useMemo( - () => - models.map((entry) => ({ - value: `${entry.provider}/${entry.id}`, - label: - entry.name === `${entry.provider}/${entry.id}` - ? entry.name - : `${entry.name} (${entry.provider}/${entry.id})`, - reasoning: entry.reasoning, - })), - [models] - ); - const modelValue = agent.model ?? ""; - const modelOptionsWithFallback = - modelValue && !modelOptions.some((option) => option.value === modelValue) - ? [{ value: modelValue, label: modelValue, reasoning: undefined }, ...modelOptions] - : modelOptions; - const selectedModel = modelOptionsWithFallback.find((option) => option.value === modelValue); - const allowThinking = selectedModel?.reasoning !== false; + const handleRename = async () => { + const next = nameDraft.trim(); + if (!next) { + setRenameError("Agent name is required."); + return; + } + if (next === agent.name) { + setRenameError(null); + return; + } + setRenameSaving(true); + setRenameError(null); + try { + const ok = await onRename(next); + if (!ok) { + setRenameError("Failed to rename agent."); + return; + } + setNameDraft(next); + } finally { + setRenameSaving(false); + } + }; + + const handleNewSession = async () => { + setSessionBusy(true); + try { + await onNewSession(); + } finally { + setSessionBusy(false); + } + }; return ( <div @@ -248,61 +89,48 @@ export const AgentSettingsPanel = ({ </div> <div className="flex flex-col gap-4 p-4"> - <section - className="rounded-md border border-border/80 bg-card/70 p-4" - data-testid="agent-settings-runtime" - > + <section className="rounded-md border border-border/80 bg-card/70 p-4" data-testid="agent-settings-identity"> <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> - Runtime settings + Identity </div> - <div className="mt-3 grid gap-3 md:grid-cols-[1.2fr_1fr]"> - <label className="flex min-w-0 flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>Model</span> - <select - className="h-10 w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground" - value={agent.model ?? ""} - onChange={(event) => { - const value = event.target.value.trim(); - onModelChange(value ? value : null); - }} - > - {modelOptionsWithFallback.length === 0 ? <option value="">No models found</option> : null} - {modelOptionsWithFallback.map((option) => ( - <option key={option.value} value={option.value}> - {option.label} - </option> - ))} - </select> - </label> - {allowThinking ? ( - <label className="flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>Thinking</span> - <select - className="h-10 rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground" - value={agent.thinkingLevel ?? ""} - onChange={(event) => { - const value = event.target.value.trim(); - onThinkingChange(value ? value : null); - }} - > - <option value="">Default</option> - <option value="off">Off</option> - <option value="minimal">Minimal</option> - <option value="low">Low</option> - <option value="medium">Medium</option> - <option value="high">High</option> - <option value="xhigh">XHigh</option> - </select> - </label> - ) : ( - <div /> - )} + <label className="mt-3 flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Agent name</span> + <input + aria-label="Agent name" + className="h-10 rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground outline-none" + value={nameDraft} + disabled={renameSaving} + onChange={(event) => setNameDraft(event.target.value)} + /> + </label> + {renameError ? ( + <div className="mt-3 rounded-md border border-destructive bg-destructive px-3 py-2 text-xs text-destructive-foreground"> + {renameError} + </div> + ) : null} + <div className="mt-3 flex justify-end"> + <button + className="rounded-md border border-transparent bg-primary/90 px-4 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-primary-foreground disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground" + type="button" + onClick={() => { + void handleRename(); + }} + disabled={renameSaving} + > + {renameSaving ? "Saving..." : "Save name"} + </button> </div> + </section> - <div className="mt-4 grid gap-3 md:grid-cols-2"> + <section className="rounded-md border border-border/80 bg-card/70 p-4" data-testid="agent-settings-display"> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> + Display + </div> + <div className="mt-3 grid gap-3 md:grid-cols-2"> <label className="flex items-center justify-between gap-3 rounded-md border border-border/80 bg-card/75 px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> <span>Show tool calls</span> <input + aria-label="Show tool calls" type="checkbox" className="h-4 w-4 rounded border-input text-foreground" checked={agent.toolCallingEnabled} @@ -312,6 +140,7 @@ export const AgentSettingsPanel = ({ <label className="flex items-center justify-between gap-3 rounded-md border border-border/80 bg-card/75 px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> <span>Show thinking</span> <input + aria-label="Show thinking" type="checkbox" className="h-4 w-4 rounded border-input text-foreground" checked={agent.showThinkingTraces} @@ -319,206 +148,25 @@ export const AgentSettingsPanel = ({ /> </label> </div> + </section> - <div className="mt-4 rounded-md border border-border/80 bg-card/70 p-4"> - <div className="flex flex-wrap items-center justify-between gap-2"> - <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> - Heartbeat config - </div> - <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - {heartbeatLoading - ? "Loading..." - : heartbeatDirty - ? "Unsaved changes" - : "All changes saved"} - </div> - </div> - {heartbeatError ? ( - <div className="mt-3 rounded-md border border-destructive bg-destructive px-3 py-2 text-xs text-destructive-foreground"> - {heartbeatError} - </div> - ) : null} - <label className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>Override defaults</span> - <input - type="checkbox" - className="h-4 w-4 rounded border-input text-foreground" - checked={heartbeatOverride} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatOverride(event.target.checked); - setHeartbeatDirty(true); - }} - /> - </label> - <label className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>Enabled</span> - <input - type="checkbox" - className="h-4 w-4 rounded border-input text-foreground" - checked={heartbeatEnabled} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatEnabled(event.target.checked); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - /> - </label> - <label className="mt-4 flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>Interval</span> - <select - className="h-10 rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground" - value={heartbeatIntervalMode === "custom" ? "custom" : heartbeatEvery} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - const value = event.target.value; - if (value === "custom") { - setHeartbeatIntervalMode("custom"); - } else { - setHeartbeatIntervalMode("preset"); - setHeartbeatEvery(value); - } - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - > - {HEARTBEAT_INTERVAL_OPTIONS.map((option) => ( - <option key={option} value={option}> - Every {option} - </option> - ))} - <option value="custom">Custom</option> - </select> - </label> - {heartbeatIntervalMode === "custom" ? ( - <input - type="number" - min={1} - className="mt-2 h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" - value={heartbeatCustomMinutes} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatCustomMinutes(event.target.value); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - placeholder="Minutes" - /> - ) : null} - <label className="mt-4 flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>Target</span> - <select - className="h-10 rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground" - value={heartbeatTargetMode} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatTargetMode(event.target.value as "last" | "none" | "custom"); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - > - <option value="last">Last channel</option> - <option value="none">No delivery</option> - <option value="custom">Custom</option> - </select> - </label> - {heartbeatTargetMode === "custom" ? ( - <input - className="mt-2 h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" - value={heartbeatTargetCustom} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatTargetCustom(event.target.value); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - placeholder="Channel id (e.g., whatsapp)" - /> - ) : null} - <label className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>Include reasoning</span> - <input - type="checkbox" - className="h-4 w-4 rounded border-input text-foreground" - checked={heartbeatIncludeReasoning} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatIncludeReasoning(event.target.checked); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - /> - </label> - <label className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>Active hours</span> - <input - type="checkbox" - className="h-4 w-4 rounded border-input text-foreground" - checked={heartbeatActiveHoursEnabled} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatActiveHoursEnabled(event.target.checked); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - /> - </label> - {heartbeatActiveHoursEnabled ? ( - <div className="mt-2 grid gap-2 sm:grid-cols-2"> - <input - type="time" - className="h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" - value={heartbeatActiveStart} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatActiveStart(event.target.value); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - /> - <input - type="time" - className="h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" - value={heartbeatActiveEnd} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatActiveEnd(event.target.value); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - /> - </div> - ) : null} - <label className="mt-4 flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> - <span>ACK max chars</span> - <input - type="number" - min={0} - className="h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" - value={heartbeatAckMaxChars} - disabled={heartbeatLoading || heartbeatSaving} - onChange={(event) => { - setHeartbeatAckMaxChars(event.target.value); - setHeartbeatOverride(true); - setHeartbeatDirty(true); - }} - /> - </label> - <div className="mt-4 flex items-center justify-between gap-2"> - <div className="text-xs text-muted-foreground"> - {heartbeatDirty ? "Remember to save changes." : "Up to date."} - </div> - <button - className="rounded-md border border-transparent bg-primary/90 px-4 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-primary-foreground disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100" - type="button" - disabled={heartbeatLoading || heartbeatSaving || !heartbeatDirty} - onClick={() => void saveHeartbeat()} - > - {heartbeatSaving ? "Saving..." : "Save heartbeat"} - </button> - </div> + <section className="rounded-md border border-border/80 bg-card/70 p-4" data-testid="agent-settings-session"> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> + Session </div> + <div className="mt-3 text-[11px] text-muted-foreground"> + Start this agent in a fresh session and clear the visible transcript in Studio. + </div> + <button + className="mt-3 w-full rounded-md border border-border/80 bg-card/75 px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground transition hover:border-border hover:bg-muted/70 disabled:cursor-not-allowed disabled:opacity-70" + type="button" + onClick={() => { + void handleNewSession(); + }} + disabled={sessionBusy} + > + {sessionBusy ? "Starting..." : "New session"} + </button> </section> <section className="rounded-md border border-destructive/30 bg-destructive/4 p-4"> diff --git a/tests/unit/agentChatPanel-controls.test.ts b/tests/unit/agentChatPanel-controls.test.ts index 0862809..5fa1006 100644 --- a/tests/unit/agentChatPanel-controls.test.ts +++ b/tests/unit/agentChatPanel-controls.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { AgentState } from "@/features/agents/state/store"; import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; const createAgent = (): AgentState => ({ agentId: "agent-1", @@ -35,30 +36,85 @@ const createAgent = (): AgentState => ({ }); describe("AgentChatPanel controls", () => { + const models: GatewayModelChoice[] = [ + { provider: "openai", id: "gpt-5", name: "gpt-5", reasoning: true }, + { provider: "openai", id: "gpt-5-mini", name: "gpt-5-mini", reasoning: false }, + ]; + afterEach(() => { cleanup(); }); - it("renders_agent_settings_control_without_inspect_copy", () => { + it("renders_runtime_controls_in_agent_header_and_no_inline_name_editor", () => { render( createElement(AgentChatPanel, { agent: createAgent(), isSelected: true, canSend: true, + models, onOpenSettings: vi.fn(), - onNameChange: vi.fn(async () => true), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), onDraftChange: vi.fn(), onSend: vi.fn(), onAvatarShuffle: vi.fn(), - onNameShuffle: vi.fn(), }) ); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Thinking")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("Agent One")).not.toBeInTheDocument(); expect(screen.getByTestId("agent-settings-toggle")).toBeInTheDocument(); expect(screen.getByLabelText("Open agent settings")).toBeInTheDocument(); expect(screen.queryByText("Inspect")).not.toBeInTheDocument(); }); + it("invokes_on_model_change_when_model_select_changes", () => { + const onModelChange = vi.fn(); + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + onOpenSettings: vi.fn(), + onModelChange, + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.change(screen.getByLabelText("Model"), { + target: { value: "openai/gpt-5-mini" }, + }); + expect(onModelChange).toHaveBeenCalledWith("openai/gpt-5-mini"); + }); + + it("invokes_on_thinking_change_when_thinking_select_changes", () => { + const onThinkingChange = vi.fn(); + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange, + onDraftChange: vi.fn(), + onSend: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.change(screen.getByLabelText("Thinking"), { + target: { value: "high" }, + }); + expect(onThinkingChange).toHaveBeenCalledWith("high"); + }); + it("invokes_on_open_settings_when_control_clicked", () => { const onOpenSettings = vi.fn(); @@ -67,12 +123,13 @@ describe("AgentChatPanel controls", () => { agent: createAgent(), isSelected: true, canSend: true, + models, onOpenSettings, - onNameChange: vi.fn(async () => true), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), onDraftChange: vi.fn(), onSend: vi.fn(), onAvatarShuffle: vi.fn(), - onNameShuffle: vi.fn(), }) ); diff --git a/tests/unit/agentSessionActions.test.ts b/tests/unit/agentSessionActions.test.ts new file mode 100644 index 0000000..db9c7b6 --- /dev/null +++ b/tests/unit/agentSessionActions.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import { buildNewSessionAgentPatch } from "@/features/agents/state/agentSessionActions"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:old-session", + status: "running", + sessionCreated: true, + awaitingUserInput: true, + hasUnseenActivity: true, + outputLines: ["> hello", "response"], + lastResult: "response", + lastDiff: "diff", + runId: "run-1", + streamText: "live", + thinkingTrace: "thinking", + latestOverride: "override", + latestOverrideKind: "heartbeat", + lastAssistantMessageAt: Date.now(), + lastActivityAt: Date.now(), + latestPreview: "preview", + lastUserMessage: "hello", + draft: "draft", + sessionSettingsSynced: true, + historyLoadedAt: Date.now(), + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "high", + avatarSeed: "seed-1", + avatarUrl: null, +}); + +describe("agent session actions", () => { + it("builds a patch that resets runtime state for a new studio session", () => { + const patch = buildNewSessionAgentPatch(createAgent(), "new-session"); + + expect(patch.sessionKey).toBe("agent:agent-1:studio:new-session"); + expect(patch.status).toBe("idle"); + expect(patch.sessionCreated).toBe(false); + expect(patch.sessionSettingsSynced).toBe(false); + expect(patch.outputLines).toEqual([]); + expect(patch.streamText).toBeNull(); + expect(patch.thinkingTrace).toBeNull(); + expect(patch.lastResult).toBeNull(); + expect(patch.lastDiff).toBeNull(); + expect(patch.historyLoadedAt).toBeNull(); + expect(patch.lastUserMessage).toBeNull(); + expect(patch.runId).toBeNull(); + expect(patch.latestPreview).toBeNull(); + expect(patch.latestOverride).toBeNull(); + expect(patch.latestOverrideKind).toBeNull(); + expect(patch.lastAssistantMessageAt).toBeNull(); + expect(patch.awaitingUserInput).toBe(false); + expect(patch.hasUnseenActivity).toBe(false); + expect(patch.draft).toBe(""); + }); +}); diff --git a/tests/unit/agentSettingsPanel.test.ts b/tests/unit/agentSettingsPanel.test.ts index e913c29..204f12b 100644 --- a/tests/unit/agentSettingsPanel.test.ts +++ b/tests/unit/agentSettingsPanel.test.ts @@ -1,6 +1,6 @@ import { createElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { AgentState } from "@/features/agents/state/store"; import { AgentSettingsPanel } from "@/features/agents/components/AgentSettingsPanel"; @@ -39,48 +39,79 @@ describe("AgentSettingsPanel", () => { cleanup(); }); - it("renders_runtime_controls_without_brain_files_section", async () => { - const call = vi.fn(async () => ({ config: {} })); - + it("renders_identity_rename_section_and_saves_trimmed_name", async () => { + const onRename = vi.fn(async () => true); render( createElement(AgentSettingsPanel, { agent: createAgent(), - client: { call } as never, - models: [{ provider: "openai", id: "gpt-5", name: "gpt-5", reasoning: true }], onClose: vi.fn(), + onRename, + onNewSession: vi.fn(), onDelete: vi.fn(), - onModelChange: vi.fn(), - onThinkingChange: vi.fn(), onToolCallingToggle: vi.fn(), onThinkingTracesToggle: vi.fn(), }) ); - expect(screen.getByText("Runtime settings")).toBeInTheDocument(); - expect(screen.queryByText("Brain files")).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Agent name"), { + target: { value: " Agent Two " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save name" })); + await waitFor(() => { - expect(call).toHaveBeenCalledWith("config.get", {}); + expect(onRename).toHaveBeenCalledWith("Agent Two"); }); }); - it("shows_heartbeat_controls_and_model_select", () => { - const call = vi.fn(async () => ({ config: {} })); - + it("keeps_show_tool_calls_and_show_thinking_toggles", () => { render( createElement(AgentSettingsPanel, { agent: createAgent(), - client: { call } as never, - models: [{ provider: "openai", id: "gpt-5", name: "gpt-5", reasoning: true }], onClose: vi.fn(), + onRename: vi.fn(async () => true), + onNewSession: vi.fn(), onDelete: vi.fn(), - onModelChange: vi.fn(), - onThinkingChange: vi.fn(), onToolCallingToggle: vi.fn(), onThinkingTracesToggle: vi.fn(), }) ); - expect(screen.getByText("Heartbeat config")).toBeInTheDocument(); - expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByLabelText("Show tool calls")).toBeInTheDocument(); + expect(screen.getByLabelText("Show thinking")).toBeInTheDocument(); + }); + + it("does_not_render_runtime_settings_section", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + onClose: vi.fn(), + onRename: vi.fn(async () => true), + onNewSession: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + }) + ); + + expect(screen.queryByText("Runtime settings")).not.toBeInTheDocument(); + expect(screen.queryByText("Brain files")).not.toBeInTheDocument(); + }); + + it("invokes_on_new_session_when_clicked", () => { + const onNewSession = vi.fn(); + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + onClose: vi.fn(), + onRename: vi.fn(async () => true), + onNewSession, + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "New session" })); + expect(onNewSession).toHaveBeenCalledTimes(1); }); });