diff --git a/.claude/skills/orchestrate/SKILL.md b/.claude/skills/orchestrate/SKILL.md new file mode 100644 index 0000000000..eb82da0395 --- /dev/null +++ b/.claude/skills/orchestrate/SKILL.md @@ -0,0 +1,509 @@ +--- +name: orchestrate +description: "Meta-agent supervisor that manages a fleet of Claude Code agents running in tmux windows. Auto-discovers spare worktrees, spawns agents, monitors state, kicks idle agents, approves safe confirmations, and recycles worktrees when done. TRIGGER when user asks to supervise agents, run parallel tasks, manage worktrees, check agent status, or orchestrate parallel work." +user-invocable: true +argument-hint: "any free text — e.g. 'start 3 agents on X Y Z', 'show status', 'add task: implement feature A', 'stop', 'how many are free?'" +metadata: + author: autogpt-team + version: "6.0.0" +--- + +# Orchestrate — Agent Fleet Supervisor + +One tmux session, N windows — each window is one agent working in its own worktree. Speak naturally; Claude maps your intent to the right scripts. + +## Scripts + +```bash +SKILLS_DIR=$(git rev-parse --show-toplevel)/.claude/skills/orchestrate/scripts +STATE_FILE=~/.claude/orchestrator-state.json +``` + +| Script | Purpose | +|---|---| +| `find-spare.sh [REPO_ROOT]` | List free worktrees — one `PATH BRANCH` per line | +| `spawn-agent.sh SESSION PATH SPARE NEW_BRANCH OBJECTIVE [PR_NUMBER] [STEPS...]` | Create window + checkout branch + launch claude + send task. **Stdout: `SESSION:WIN` only** | +| `recycle-agent.sh WINDOW PATH SPARE_BRANCH` | Kill window + restore spare branch | +| `run-loop.sh` | **Mechanical babysitter** — idle restart + dialog approval + recycle on ORCHESTRATOR:DONE + supervisor health check + all-done notification | +| `verify-complete.sh WINDOW` | Verify PR is done: checkpoints ✓ + 0 unresolved threads + CI green. Repo auto-derived from state file `.repo` or git remote. | +| `notify.sh MESSAGE` | Send notification via Discord webhook (env `DISCORD_WEBHOOK_URL` or state `.discord_webhook`), macOS notification center, and stdout | +| `capacity.sh [REPO_ROOT]` | Print available + in-use worktrees | +| `status.sh` | Print fleet status + live pane commands | +| `poll-cycle.sh` | One monitoring cycle — classifies panes, tracks checkpoints, returns JSON action array | +| `classify-pane.sh WINDOW` | Classify one pane state | + +## Supervision model + +``` +Orchestrating Claude (this Claude session — IS the supervisor) + └── Reads pane output, checks CI, intervenes with targeted guidance + run-loop.sh (separate tmux window, every 30s) + └── Mechanical only: idle restart, dialog approval, recycle on ORCHESTRATOR:DONE +``` + +**You (the orchestrating Claude)** are the supervisor. After spawning agents, stay in this conversation and actively monitor: poll each agent's pane every 2-3 minutes, check CI, nudge stalled agents, and verify completions. Do not spawn a separate supervisor Claude window — it loses context, is hard to observe, and compounds context compression problems. + +**run-loop.sh** is the mechanical layer — zero tokens, handles things that need no judgment: restart crashed agents, press Enter on dialogs, recycle completed worktrees (only after `verify-complete.sh` passes). + +## Checkpoint protocol + +Agents output checkpoints as they complete each required step: + +``` +CHECKPOINT: +``` + +Required steps are passed as args to `spawn-agent.sh` (e.g. `pr-address pr-test`). `run-loop.sh` will not recycle a window until all required checkpoints are found in the pane output. If `verify-complete.sh` fails, the agent is re-briefed automatically. + +## Worktree lifecycle + +```text +spare/N branch → spawn-agent.sh (--session-id UUID) → window + feat/branch + claude running + ↓ + CHECKPOINT: (as steps complete) + ↓ + ORCHESTRATOR:DONE + ↓ + verify-complete.sh: checkpoints ✓ + 0 threads + CI green + ↓ + state → "done", notify, window KEPT OPEN + ↓ + user/orchestrator explicitly requests recycle + ↓ + recycle-agent.sh → spare/N (free again) +``` + +**Windows are never auto-killed.** The worktree stays on its branch, the session stays alive. The agent is done working but the window, git state, and Claude session are all preserved until you choose to recycle. + +**To resume a done or crashed session:** +```bash +# Resume by stored session ID (preferred — exact session, full context) +claude --resume SESSION_ID --permission-mode bypassPermissions + +# Or resume most recent session in that worktree directory +cd /path/to/worktree && claude --continue --permission-mode bypassPermissions +``` + +**To manually recycle when ready:** +```bash +bash ~/.claude/orchestrator/scripts/recycle-agent.sh SESSION:WIN WORKTREE_PATH spare/N +# Then update state: +jq --arg w "SESSION:WIN" '.agents |= map(if .window == $w then .state = "recycled" else . end)' \ + ~/.claude/orchestrator-state.json > /tmp/orch.tmp && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json +``` + +## State file (`~/.claude/orchestrator-state.json`) + +Never committed to git. You maintain this file directly using `jq` + atomic writes (`.tmp` → `mv`). + +```json +{ + "active": true, + "tmux_session": "autogpt1", + "idle_threshold_seconds": 300, + "loop_window": "autogpt1:5", + "repo": "Significant-Gravitas/AutoGPT", + "discord_webhook": "https://discord.com/api/webhooks/...", + "last_poll_at": 0, + "agents": [ + { + "window": "autogpt1:3", + "worktree": "AutoGPT6", + "worktree_path": "/path/to/AutoGPT6", + "spare_branch": "spare/6", + "branch": "feat/my-feature", + "objective": "Implement X and open a PR", + "pr_number": "12345", + "session_id": "550e8400-e29b-41d4-a716-446655440000", + "steps": ["pr-address", "pr-test"], + "checkpoints": ["pr-address"], + "state": "running", + "last_output_hash": "", + "last_seen_at": 0, + "spawned_at": 0, + "idle_since": 0, + "revision_count": 0, + "last_rebriefed_at": 0 + } + ] +} +``` + +Top-level optional fields: +- `repo` — GitHub `owner/repo` for CI/thread checks. Auto-derived from git remote if omitted. +- `discord_webhook` — Discord webhook URL for completion notifications. Also reads `DISCORD_WEBHOOK_URL` env var. + +Per-agent fields: +- `session_id` — UUID passed to `claude --session-id` at spawn; use with `claude --resume UUID` to restore exact session context after a crash or window close. +- `last_rebriefed_at` — Unix timestamp of last re-brief; enforces 5-min cooldown to prevent spam. + +Agent states: `running` | `idle` | `stuck` | `waiting_approval` | `complete` | `done` | `escalated` + +`done` means verified complete — window is still open, session still alive, worktree still on task branch. Not recycled yet. + +## Serial /pr-test rule + +`/pr-test` and `/pr-test --fix` run local Docker + integration tests that use shared ports, a shared database, and shared build caches. **Running two `/pr-test` jobs simultaneously will cause port conflicts and database corruption.** + +**Rule: only one `/pr-test` runs at a time. The orchestrator serializes them.** + +You (the orchestrating Claude) own the test queue: +1. Agents do `pr-review` and `pr-address` in parallel — that's safe (they only push code and reply to GitHub). +2. When a PR needs local testing, add it to your mental queue — don't give agents a `pr-test` step. +3. Run `/pr-test https://github.com/OWNER/REPO/pull/PR_NUMBER --fix` yourself, sequentially. +4. Feed results back to the relevant agent via `tmux send-keys`: + ```bash + tmux send-keys -t SESSION:WIN "Local tests for PR #N: . Fix any failures and push, then output ORCHESTRATOR:DONE." + sleep 0.3 + tmux send-keys -t SESSION:WIN Enter + ``` +5. Wait for CI to confirm green before marking the agent done. + +If multiple PRs need testing at the same time, pick the one furthest along (fewest pending CI checks) and test it first. Only start the next test after the previous one completes. + +## Session restore (tested and confirmed) + +Agent sessions are saved to disk. To restore a closed or crashed session: + +```bash +# If session_id is in state (preferred): +NEW_WIN=$(tmux new-window -t SESSION -n WORKTREE_NAME -P -F '#{window_index}') +tmux send-keys -t "SESSION:${NEW_WIN}" "cd /path/to/worktree && claude --resume SESSION_ID --permission-mode bypassPermissions" Enter + +# If no session_id (use --continue for most recent session in that directory): +tmux send-keys -t "SESSION:${NEW_WIN}" "cd /path/to/worktree && claude --continue --permission-mode bypassPermissions" Enter +``` + +`--continue` restores the full conversation history including all tool calls, file edits, and context. The agent resumes exactly where it left off. After restoring, update the window address in the state file: + +```bash +jq --arg old "SESSION:OLD_WIN" --arg new "SESSION:NEW_WIN" \ + '(.agents[] | select(.window == $old)).window = $new' \ + ~/.claude/orchestrator-state.json > /tmp/orch.tmp && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json +``` + +## Intent → action mapping + +Match the user's message to one of these intents: + +| The user says something like… | What to do | +|---|---| +| "status", "what's running", "show agents" | Run `status.sh` + `capacity.sh`, show output | +| "how many free", "capacity", "available worktrees" | Run `capacity.sh`, show output | +| "start N agents on X, Y, Z" or "run these tasks: …" | See **Spawning agents** below | +| "add task: …", "add one more agent for …" | See **Adding an agent** below | +| "stop", "shut down", "pause the fleet" | See **Stopping** below | +| "poll", "check now", "run a cycle" | Run `poll-cycle.sh`, process actions | +| "recycle window X", "free up autogpt3" | Run `recycle-agent.sh` directly | + +When the intent is ambiguous, show capacity first and ask what tasks to run. + +## Spawning agents + +### 1. Resolve tmux session + +```bash +tmux list-sessions -F "#{session_name}: #{session_windows} windows" 2>/dev/null +``` + +Use an existing session. **Never create a tmux session from within Claude** — it becomes a child of Claude's process and dies when the session ends. If no session exists, tell the user to run `tmux new-session -d -s autogpt1` in their terminal first, then re-invoke `/orchestrate`. + +### 2. Show available capacity + +```bash +bash $SKILLS_DIR/capacity.sh $(git rev-parse --show-toplevel) +``` + +### 3. Collect tasks from the user + +For each task, gather: +- **objective** — what to do (e.g. "implement feature X and open a PR") +- **branch name** — e.g. `feat/my-feature` (derive from objective if not given) +- **pr_number** — GitHub PR number if working on an existing PR (for verification) +- **steps** — required checkpoint names in order (e.g. `pr-address pr-test`) — derive from objective + +Ask for `idle_threshold_seconds` only if the user mentions it (default: 300). + +Never ask the user to specify a worktree — auto-assign from `find-spare.sh`. + +### 4. Spawn one agent per task + +```bash +# Get ordered list of spare worktrees +SPARE_LIST=$(bash $SKILLS_DIR/find-spare.sh $(git rev-parse --show-toplevel)) + +# For each task, take the next spare line: +WORKTREE_PATH=$(echo "$SPARE_LINE" | awk '{print $1}') +SPARE_BRANCH=$(echo "$SPARE_LINE" | awk '{print $2}') + +# With PR number and required steps: +WINDOW=$(bash $SKILLS_DIR/spawn-agent.sh "$SESSION" "$WORKTREE_PATH" "$SPARE_BRANCH" "$NEW_BRANCH" "$OBJECTIVE" "$PR_NUMBER" "pr-address" "pr-test") + +# Without PR (new work): +WINDOW=$(bash $SKILLS_DIR/spawn-agent.sh "$SESSION" "$WORKTREE_PATH" "$SPARE_BRANCH" "$NEW_BRANCH" "$OBJECTIVE") +``` + +Build an agent record and append it to the state file. If the state file doesn't exist yet, initialize it: + +```bash +# Derive repo from git remote (used by verify-complete.sh + supervisor) +REPO=$(git remote get-url origin 2>/dev/null | sed 's|.*github\.com[:/]||; s|\.git$||' || echo "") + +jq -n \ + --arg session "$SESSION" \ + --arg repo "$REPO" \ + --argjson threshold 300 \ + '{active:true, tmux_session:$session, idle_threshold_seconds:$threshold, + repo:$repo, loop_window:null, supervisor_window:null, last_poll_at:0, agents:[]}' \ + > ~/.claude/orchestrator-state.json +``` + +Optionally add a Discord webhook for completion notifications: +```bash +jq --arg hook "$DISCORD_WEBHOOK_URL" '.discord_webhook = $hook' ~/.claude/orchestrator-state.json \ + > /tmp/orch.tmp && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json +``` + +`spawn-agent.sh` writes the initial agent record (window, worktree_path, branch, objective, state, etc.) to the state file automatically — **do not append the record again after calling it.** The record already exists and `pr_number`/`steps` are patched in by the script itself. + +### 5. Start the mechanical babysitter + +```bash +LOOP_WIN=$(tmux new-window -t "$SESSION" -n "orchestrator" -P -F '#{window_index}') +LOOP_WINDOW="${SESSION}:${LOOP_WIN}" +tmux send-keys -t "$LOOP_WINDOW" "bash $SKILLS_DIR/run-loop.sh" Enter + +jq --arg w "$LOOP_WINDOW" '.loop_window = $w' ~/.claude/orchestrator-state.json \ + > /tmp/orch.tmp && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json +``` + +### 6. Begin supervising directly in this conversation + +You are the supervisor. After spawning, immediately start your first poll loop (see **Supervisor duties** below) and continue every 2-3 minutes. Do NOT spawn a separate supervisor Claude window. + +## Adding an agent + +Find the next spare worktree, then spawn and append to state — same as steps 2–4 above but for a single task. If no spare worktrees are available, tell the user. + +## Supervisor duties (YOUR job, every 2-3 min in this conversation) + +You are the supervisor. Run this poll loop directly in your Claude session — not in a separate window. + +### Poll loop mechanism + +You are reactive — you only act when a tool completes or the user sends a message. To create a self-sustaining poll loop without user involvement: + +1. Start each poll with `run_in_background: true` + a sleep before the work: + ```bash + sleep 120 && tmux capture-pane -t autogpt1:0 -p -S -200 | tail -40 + # + similar for each active window + ``` +2. When the background job notifies you, read the pane output and take action. +3. Immediately schedule the next background poll — this keeps the loop alive. +4. Stop scheduling when all agents are done/escalated. + +**Never tell the user "I'll poll every 2-3 minutes"** — that does nothing without a trigger. Start the background job instead. + +### Each poll: what to check + +```bash +# 1. Read state +cat ~/.claude/orchestrator-state.json | jq '.agents[] | {window, worktree, branch, state, pr_number, checkpoints}' + +# 2. For each running/stuck/idle agent, capture pane +tmux capture-pane -t SESSION:WIN -p -S -200 | tail -60 +``` + +For each agent, decide: + +| What you see | Action | +|---|---| +| Spinner / tools running | Do nothing — agent is working | +| Idle `❯` prompt, no `ORCHESTRATOR:DONE` | Stalled — send specific nudge with objective from state | +| Stuck in error loop | Send targeted fix with exact error + solution | +| Waiting for input / question | Answer and unblock via `tmux send-keys` | +| CI red | `gh pr checks PR_NUMBER --repo REPO` → tell agent exactly what's failing | +| Context compacted / agent lost | Send recovery: `cat ~/.claude/orchestrator-state.json | jq '.agents[] | select(.window=="WIN")'` + `gh pr view PR_NUMBER --json title,body` | +| `ORCHESTRATOR:DONE` in output | Run `verify-complete.sh` — if it fails, re-brief with specific reason | + +### Strict ORCHESTRATOR:DONE gate + +`verify-complete.sh` handles the main checks automatically (checkpoints, threads, CHANGES_REQUESTED, CI green, spawned_at). Run it: + +```bash +SKILLS_DIR=~/.claude/orchestrator/scripts +bash $SKILLS_DIR/verify-complete.sh SESSION:WIN +``` + +If it passes → run-loop.sh will recycle the window automatically. No manual action needed. +If it fails → re-brief the agent with the failure reason. Never manually mark state `done` to bypass this. + +### Re-brief a stalled agent + +```bash +OBJ=$(jq -r --arg w SESSION:WIN '.agents[] | select(.window==$w) | .objective' ~/.claude/orchestrator-state.json) +PR=$(jq -r --arg w SESSION:WIN '.agents[] | select(.window==$w) | .pr_number' ~/.claude/orchestrator-state.json) +tmux send-keys -t SESSION:WIN "You appear stalled. Your objective: $OBJ. Check: gh pr view $PR --json title,body,headRefName to reorient." +sleep 0.3 +tmux send-keys -t SESSION:WIN Enter +``` + +If `image_path` is set on the agent record, include: "Re-read context at IMAGE_PATH with the Read tool." + +## Self-recovery protocol (agents) + +spawn-agent.sh automatically includes this instruction in every objective: + +> If your context compacts and you lose track of what to do, run: +> `cat ~/.claude/orchestrator-state.json | jq '.agents[] | select(.window=="SESSION:WIN")'` +> and `gh pr view PR_NUMBER --json title,body,headRefName` to reorient. +> Output each completed step as `CHECKPOINT:` on its own line. + +## Passing images and screenshots to agents + +`tmux send-keys` is text-only — you cannot paste a raw image into a pane. To give an agent visual context (screenshots, diagrams, mockups): + +1. **Save the image to a temp file** with a stable path: + ```bash + # If the user drags in a screenshot or you receive a file path: + IMAGE_PATH="/tmp/orchestrator-context-$(date +%s).png" + cp "$USER_PROVIDED_PATH" "$IMAGE_PATH" + ``` + +2. **Reference the path in the objective string**: + ```bash + OBJECTIVE="Implement the layout shown in /tmp/orchestrator-context-1234567890.png. Read that image first with the Read tool to understand the design." + ``` + +3. The agent uses its `Read` tool to view the image at startup — Claude Code agents are multimodal and can read image files directly. + +**Rule**: always use `/tmp/orchestrator-context-.png` as the naming convention so the supervisor knows what to look for if it needs to re-brief an agent with the same image. + +--- + +## Orchestrator final evaluation (YOU decide, not the script) + +`verify-complete.sh` is a gate — it blocks premature marking. But it cannot tell you if the work is actually good. That is YOUR job. + +When run-loop marks an agent `pending_evaluation` and you're notified, do all of these before marking done: + +### 1. Run /pr-test (required, serialized, use TodoWrite to queue) + +`/pr-test` is the only reliable confirmation that the objective is actually met. Run it yourself, not the agent. + +**When multiple PRs reach `pending_evaluation` at the same time, use TodoWrite to queue them:** +``` +- [ ] /pr-test PR #12636 — fix copilot retry logic +- [ ] /pr-test PR #12699 — builder chat panel +``` +Run one at a time. Check off as you go. + +``` +/pr-test https://github.com/Significant-Gravitas/AutoGPT/pull/PR_NUMBER +``` + +**/pr-test can be lazy** — if it gives vague output, re-run with full context: + +``` +/pr-test https://github.com/OWNER/REPO/pull/PR_NUMBER +Context: This PR implements . Key files: . +Please verify: . +``` + +Only one `/pr-test` at a time — they share ports and DB. + +### 2. Do your own evaluation + +1. **Read the PR diff and objective** — does the code actually implement what was asked? Is anything obviously missing or half-done? +2. **Read the resolved threads** — were comments addressed with real fixes, or just dismissed/resolved without changes? +3. **Check CI run names** — any suspicious retries that shouldn't have passed? +4. **Check the PR description** — title, summary, test plan complete? + +### 3. Decide + +- `/pr-test` passes + evaluation looks good → mark `done` in state, tell the user the PR is ready, ask if window should be closed +- `/pr-test` fails or evaluation finds gaps → re-brief the agent with specific failures, set state back to `running` + +**Never mark done based purely on script output.** You hold the full objective context; the script does not. + +```bash +# Mark done after your positive evaluation: +jq --arg w "SESSION:WIN" '(.agents[] | select(.window == $w)).state = "done"' \ + ~/.claude/orchestrator-state.json > /tmp/orch.tmp && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json +``` + +## When to stop the fleet + +Stop the fleet (`active = false`) when **all** of the following are true: + +| Check | How to verify | +|---|---| +| All agents are `done` or `escalated` | `jq '[.agents[] | select(.state | test("running\|stuck\|idle\|waiting_approval"))] | length' ~/.claude/orchestrator-state.json` == 0 | +| All PRs have 0 unresolved review threads | GraphQL `isResolved` check per PR | +| All PRs have green CI **on a run triggered after the agent's last push** | `gh run list --branch BRANCH --limit 1` timestamp > `spawned_at` in state | +| No agents are `escalated` without human review | If any are escalated, surface to user first | + +**Do NOT stop just because agents output `ORCHESTRATOR:DONE`.** That is a signal to verify, not a signal to stop. + +**Do stop** if the user explicitly says "stop", "shut down", or "kill everything", even with agents still running. + +```bash +# Graceful stop +jq '.active = false' ~/.claude/orchestrator-state.json > /tmp/orch.tmp \ + && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json + +LOOP_WINDOW=$(jq -r '.loop_window // ""' ~/.claude/orchestrator-state.json) +[ -n "$LOOP_WINDOW" ] && tmux kill-window -t "$LOOP_WINDOW" 2>/dev/null || true +``` + +Does **not** recycle running worktrees — agents may still be mid-task. Run `capacity.sh` to see what's still in progress. + +## tmux send-keys pattern + +**Always split long messages into text + Enter as two separate calls with a sleep between them.** If sent as one call (`"text" Enter`), Enter can fire before the full string is buffered into Claude's input — leaving the message stuck as `[Pasted text +N lines]` unsent. + +```bash +# CORRECT — text then Enter separately +tmux send-keys -t "$WINDOW" "your long message here" +sleep 0.3 +tmux send-keys -t "$WINDOW" Enter + +# WRONG — Enter may fire before text is buffered +tmux send-keys -t "$WINDOW" "your long message here" Enter +``` + +Short single-character sends (`y`, `Down`, empty Enter for dialog approval) are safe to combine since they have no buffering lag. + +--- + +## Protected worktrees + +Some worktrees must **never** be used as spare worktrees for agent tasks because they host files critical to the orchestrator itself: + +| Worktree | Protected branch | Why | +|---|---|---| +| `AutoGPT1` | `dx/orchestrate-skill` | Hosts the orchestrate skill scripts. `recycle-agent.sh` would check out `spare/1`, wiping `.claude/skills/` and breaking all subsequent `spawn-agent.sh` calls. | + +**Rule**: when selecting spare worktrees via `find-spare.sh`, skip any worktree whose CURRENT branch matches a protected branch. If you accidentally spawn an agent in a protected worktree, do not let `recycle-agent.sh` run on it — manually restore the branch after the agent finishes. + +When `dx/orchestrate-skill` is merged into `dev`, `AutoGPT1` becomes a normal spare again. + +--- + +## Key rules + +1. **Scripts do all the heavy lifting** — don't reimplement their logic inline in this file +2. **Never ask the user to pick a worktree** — auto-assign from `find-spare.sh` output +3. **Never restart a running agent** — only restart on `idle` kicks (foreground is a shell) +4. **Auto-dismiss settings dialogs** — if "Enter to confirm" appears, send Down+Enter +5. **Always `--permission-mode bypassPermissions`** on every spawn +6. **Escalate after 3 kicks** — mark `escalated`, surface to user +7. **Atomic state writes** — always write to `.tmp` then `mv` +8. **Never approve destructive commands** outside the worktree scope — when in doubt, escalate +9. **Never recycle without verification** — `verify-complete.sh` must pass before recycling +10. **No TASK.md files** — commit risk; use state file + `gh pr view` for agent context persistence +11. **Re-brief stalled agents** — read objective from state file + `gh pr view`, send via tmux +12. **ORCHESTRATOR:DONE is a signal to verify, not to accept** — always run `verify-complete.sh` and check CI run timestamp before recycling +13. **Protected worktrees** — never use the worktree hosting the skill scripts as a spare +14. **Images via file path** — save screenshots to `/tmp/orchestrator-context-.png`, pass path in objective; agents read with the `Read` tool +15. **Split send-keys** — always separate text and Enter with `sleep 0.3` between calls for long strings diff --git a/.claude/skills/orchestrate/scripts/capacity.sh b/.claude/skills/orchestrate/scripts/capacity.sh new file mode 100755 index 0000000000..1bbf376297 --- /dev/null +++ b/.claude/skills/orchestrate/scripts/capacity.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# capacity.sh — show fleet capacity: available spare worktrees + in-use agents +# +# Usage: capacity.sh [REPO_ROOT] +# REPO_ROOT defaults to the root worktree of the current git repo. +# +# Reads: ~/.claude/orchestrator-state.json (skipped if missing or corrupt) + +set -euo pipefail + +SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE_FILE="${ORCHESTRATOR_STATE_FILE:-$HOME/.claude/orchestrator-state.json}" +REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || echo "")}" + +echo "=== Available (spare) worktrees ===" +if [ -n "$REPO_ROOT" ]; then + SPARE=$("$SCRIPTS_DIR/find-spare.sh" "$REPO_ROOT" 2>/dev/null || echo "") +else + SPARE=$("$SCRIPTS_DIR/find-spare.sh" 2>/dev/null || echo "") +fi + +if [ -z "$SPARE" ]; then + echo " (none)" +else + while IFS= read -r line; do + [ -z "$line" ] && continue + echo " ✓ $line" + done <<< "$SPARE" +fi + +echo "" +echo "=== In-use worktrees ===" +if [ -f "$STATE_FILE" ] && jq -e '.' "$STATE_FILE" >/dev/null 2>&1; then + IN_USE=$(jq -r '.agents[] | select(.state != "done") | " [\(.state)] \(.worktree_path) → \(.branch)"' \ + "$STATE_FILE" 2>/dev/null || echo "") + if [ -n "$IN_USE" ]; then + echo "$IN_USE" + else + echo " (none)" + fi +else + echo " (no active state file)" +fi diff --git a/.claude/skills/orchestrate/scripts/classify-pane.sh b/.claude/skills/orchestrate/scripts/classify-pane.sh new file mode 100755 index 0000000000..57504c72ce --- /dev/null +++ b/.claude/skills/orchestrate/scripts/classify-pane.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# classify-pane.sh — Classify the current state of a tmux pane +# +# Usage: classify-pane.sh +# tmux-target: e.g. "work:0", "work:1.0" +# +# Output (stdout): JSON object: +# { "state": "running|idle|waiting_approval|complete", "reason": "...", "pane_cmd": "..." } +# +# Exit codes: 0=ok, 1=error (invalid target or tmux window not found) + +set -euo pipefail + +TARGET="${1:-}" + +if [ -z "$TARGET" ]; then + echo '{"state":"error","reason":"no target provided","pane_cmd":""}' + exit 1 +fi + +# Validate tmux target format: session:window or session:window.pane +if ! [[ "$TARGET" =~ ^[a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+(\.[0-9]+)?$ ]]; then + echo '{"state":"error","reason":"invalid tmux target format","pane_cmd":""}' + exit 1 +fi + +# Check session exists (use %%:* to extract session name from session:window) +if ! tmux list-windows -t "${TARGET%%:*}" &>/dev/null 2>&1; then + echo '{"state":"error","reason":"tmux target not found","pane_cmd":""}' + exit 1 +fi + +# Get the current foreground command in the pane +PANE_CMD=$(tmux display-message -t "$TARGET" -p '#{pane_current_command}' 2>/dev/null || echo "unknown") + +# Capture and strip ANSI codes (use perl for cross-platform compatibility — BSD sed lacks \x1b support) +RAW=$(tmux capture-pane -t "$TARGET" -p -S -50 2>/dev/null || echo "") +CLEAN=$(echo "$RAW" | perl -pe 's/\x1b\[[0-9;]*[a-zA-Z]//g; s/\x1b\(B//g; s/\x1b\[\?[0-9]*[hl]//g; s/\r//g' \ + | grep -v '^[[:space:]]*$' || true) + +# --- Check: explicit completion marker --- +# Must be on its own line (not buried in the objective text sent at spawn time). +if echo "$CLEAN" | grep -qE "^[[:space:]]*ORCHESTRATOR:DONE[[:space:]]*$"; then + jq -n --arg cmd "$PANE_CMD" '{"state":"complete","reason":"ORCHESTRATOR:DONE marker found","pane_cmd":$cmd}' + exit 0 +fi + +# --- Check: Claude Code approval prompt patterns --- +LAST_40=$(echo "$CLEAN" | tail -40) +APPROVAL_PATTERNS=( + "Do you want to proceed" + "Do you want to make this" + "\\[y/n\\]" + "\\[Y/n\\]" + "\\[n/Y\\]" + "Proceed\\?" + "Allow this command" + "Run bash command" + "Allow bash" + "Would you like" + "Press enter to continue" + "Esc to cancel" +) +for pattern in "${APPROVAL_PATTERNS[@]}"; do + if echo "$LAST_40" | grep -qiE "$pattern"; then + jq -n --arg pattern "$pattern" --arg cmd "$PANE_CMD" \ + '{"state":"waiting_approval","reason":"approval pattern: \($pattern)","pane_cmd":$cmd}' + exit 0 + fi +done + +# --- Check: shell prompt (claude has exited) --- +# If the foreground process is a shell (not claude/node), the agent has exited +case "$PANE_CMD" in + zsh|bash|fish|sh|dash|tcsh|ksh) + jq -n --arg cmd "$PANE_CMD" \ + '{"state":"idle","reason":"agent exited — shell prompt active","pane_cmd":$cmd}' + exit 0 + ;; +esac + +# Agent is still running (claude/node/python is the foreground process) +jq -n --arg cmd "$PANE_CMD" \ + '{"state":"running","reason":"foreground process: \($cmd)","pane_cmd":$cmd}' +exit 0 diff --git a/.claude/skills/orchestrate/scripts/find-spare.sh b/.claude/skills/orchestrate/scripts/find-spare.sh new file mode 100755 index 0000000000..e374a41c9b --- /dev/null +++ b/.claude/skills/orchestrate/scripts/find-spare.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# find-spare.sh — list worktrees on spare/N branches (free to use) +# +# Usage: find-spare.sh [REPO_ROOT] +# REPO_ROOT defaults to the root worktree containing the current git repo. +# +# Output (stdout): one line per available worktree: "PATH BRANCH" +# e.g.: /Users/me/Code/AutoGPT3 spare/3 + +set -euo pipefail + +REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || echo "")}" +if [ -z "$REPO_ROOT" ]; then + echo "Error: not inside a git repo and no REPO_ROOT provided" >&2 + exit 1 +fi + +git -C "$REPO_ROOT" worktree list --porcelain \ + | awk ' + /^worktree / { path = substr($0, 10) } + /^branch / { branch = substr($0, 8); print path " " branch } + ' \ + | { grep -E " refs/heads/spare/[0-9]+$" || true; } \ + | sed 's|refs/heads/||' diff --git a/.claude/skills/orchestrate/scripts/notify.sh b/.claude/skills/orchestrate/scripts/notify.sh new file mode 100755 index 0000000000..ace46cc152 --- /dev/null +++ b/.claude/skills/orchestrate/scripts/notify.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# notify.sh — send a fleet notification message +# +# Delivery order (first available wins): +# 1. Discord webhook — DISCORD_WEBHOOK_URL env var OR state file .discord_webhook +# 2. macOS notification center — osascript (silent fail if unavailable) +# 3. Stdout only +# +# Usage: notify.sh MESSAGE +# Exit: always 0 (notification failure must not abort the caller) + +MESSAGE="${1:-}" +[ -z "$MESSAGE" ] && exit 0 + +STATE_FILE="${ORCHESTRATOR_STATE_FILE:-$HOME/.claude/orchestrator-state.json}" + +# --- Resolve Discord webhook --- +WEBHOOK="${DISCORD_WEBHOOK_URL:-}" +if [ -z "$WEBHOOK" ] && [ -f "$STATE_FILE" ]; then + WEBHOOK=$(jq -r '.discord_webhook // ""' "$STATE_FILE" 2>/dev/null || echo "") +fi + +# --- Discord delivery --- +if [ -n "$WEBHOOK" ]; then + PAYLOAD=$(jq -n --arg msg "$MESSAGE" '{"content": $msg}') + curl -s -X POST "$WEBHOOK" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" > /dev/null 2>&1 || true +fi + +# --- macOS notification center (silent if not macOS or osascript missing) --- +if command -v osascript &>/dev/null 2>&1; then + # Escape single quotes for AppleScript + SAFE_MSG=$(echo "$MESSAGE" | sed "s/'/\\\\'/g") + osascript -e "display notification \"${SAFE_MSG}\" with title \"Orchestrator\"" 2>/dev/null || true +fi + +# Always print to stdout so run-loop.sh logs it +echo "$MESSAGE" +exit 0 diff --git a/.claude/skills/orchestrate/scripts/poll-cycle.sh b/.claude/skills/orchestrate/scripts/poll-cycle.sh new file mode 100755 index 0000000000..dafd307bf3 --- /dev/null +++ b/.claude/skills/orchestrate/scripts/poll-cycle.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# poll-cycle.sh — Single orchestrator poll cycle +# +# Reads ~/.claude/orchestrator-state.json, classifies each agent, updates state, +# and outputs a JSON array of actions for Claude to take. +# +# Usage: poll-cycle.sh +# Output (stdout): JSON array of action objects +# [{ "window": "work:0", "action": "kick|approve|none", "state": "...", +# "worktree": "...", "objective": "...", "reason": "..." }] +# +# The state file is updated in-place (atomic write via .tmp). + +set -euo pipefail + +STATE_FILE="${ORCHESTRATOR_STATE_FILE:-$HOME/.claude/orchestrator-state.json}" +SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLASSIFY="$SCRIPTS_DIR/classify-pane.sh" + +# Cross-platform md5: always outputs just the hex digest +md5_hash() { + if command -v md5sum &>/dev/null; then + md5sum | awk '{print $1}' + else + md5 | awk '{print $NF}' + fi +} + +# Clean up temp file on any exit (avoids stale .tmp if jq write fails) +trap 'rm -f "${STATE_FILE}.tmp"' EXIT + +# Ensure state file exists +if [ ! -f "$STATE_FILE" ]; then + echo '{"active":false,"agents":[]}' > "$STATE_FILE" +fi + +# Validate JSON upfront before any jq reads that run under set -e. +# A truncated/corrupt file (e.g. from a SIGKILL mid-write) would otherwise +# abort the script at the ACTIVE read below without emitting any JSON output. +if ! jq -e '.' "$STATE_FILE" >/dev/null 2>&1; then + echo "State file parse error — check $STATE_FILE" >&2 + echo "[]" + exit 0 +fi + +ACTIVE=$(jq -r '.active // false' "$STATE_FILE") +if [ "$ACTIVE" != "true" ]; then + echo "[]" + exit 0 +fi + +NOW=$(date +%s) +IDLE_THRESHOLD=$(jq -r '.idle_threshold_seconds // 300' "$STATE_FILE") + +ACTIONS="[]" +UPDATED_AGENTS="[]" + +# Read agents as newline-delimited JSON objects. +# jq exits non-zero when .agents[] has no matches on an empty array, which is valid — +# so we suppress that exit code and separately validate the file is well-formed JSON. +if ! AGENTS_JSON=$(jq -e -c '.agents // empty | .[]' "$STATE_FILE" 2>/dev/null); then + if ! jq -e '.' "$STATE_FILE" > /dev/null 2>&1; then + echo "State file parse error — check $STATE_FILE" >&2 + fi + echo "[]" + exit 0 +fi + +if [ -z "$AGENTS_JSON" ]; then + echo "[]" + exit 0 +fi + +while IFS= read -r agent; do + [ -z "$agent" ] && continue + + # Use // "" defaults so a single malformed field doesn't abort the whole cycle + WINDOW=$(echo "$agent" | jq -r '.window // ""') + WORKTREE=$(echo "$agent" | jq -r '.worktree // ""') + OBJECTIVE=$(echo "$agent"| jq -r '.objective // ""') + STATE=$(echo "$agent" | jq -r '.state // "running"') + LAST_HASH=$(echo "$agent"| jq -r '.last_output_hash // ""') + IDLE_SINCE=$(echo "$agent"| jq -r '.idle_since // 0') + REVISION_COUNT=$(echo "$agent"| jq -r '.revision_count // 0') + + # Validate window format to prevent tmux target injection. + # Allow session:window (numeric or named) and session:window.pane + if ! [[ "$WINDOW" =~ ^[a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+(\.[0-9]+)?$ ]]; then + echo "Skipping agent with invalid window value: $WINDOW" >&2 + UPDATED_AGENTS=$(echo "$UPDATED_AGENTS" | jq --argjson a "$agent" '. + [$a]') + continue + fi + + # Pass-through terminal-state agents + if [[ "$STATE" == "done" || "$STATE" == "escalated" || "$STATE" == "complete" || "$STATE" == "pending_evaluation" ]]; then + UPDATED_AGENTS=$(echo "$UPDATED_AGENTS" | jq --argjson a "$agent" '. + [$a]') + continue + fi + + # Classify pane. + # classify-pane.sh always emits JSON before exit (even on error), so using + # "|| echo '...'" would concatenate two JSON objects when it exits non-zero. + # Use "|| true" inside the substitution so set -euo pipefail does not abort + # the poll cycle when classify exits with a non-zero status code. + CLASSIFICATION=$("$CLASSIFY" "$WINDOW" 2>/dev/null || true) + [ -z "$CLASSIFICATION" ] && CLASSIFICATION='{"state":"error","reason":"classify failed","pane_cmd":"unknown"}' + + PANE_STATE=$(echo "$CLASSIFICATION" | jq -r '.state') + PANE_REASON=$(echo "$CLASSIFICATION" | jq -r '.reason') + + # Capture full pane output once — used for hash (stuck detection) and checkpoint parsing. + # Use -S -500 to get the last ~500 lines of scrollback so checkpoints aren't missed. + RAW=$(tmux capture-pane -t "$WINDOW" -p -S -500 2>/dev/null || echo "") + + # --- Checkpoint tracking --- + # Parse any "CHECKPOINT:" lines the agent has output and merge into state file. + # The agent writes these as it completes each required step so verify-complete.sh can gate recycling. + EXISTING_CPS=$(echo "$agent" | jq -c '.checkpoints // []') + NEW_CHECKPOINTS_JSON="$EXISTING_CPS" + if [ -n "$RAW" ]; then + FOUND_CPS=$(echo "$RAW" \ + | grep -oE "CHECKPOINT:[a-zA-Z0-9_-]+" \ + | sed 's/CHECKPOINT://' \ + | sort -u \ + | jq -R . | jq -s . 2>/dev/null || echo "[]") + NEW_CHECKPOINTS_JSON=$(jq -n \ + --argjson existing "$EXISTING_CPS" \ + --argjson found "$FOUND_CPS" \ + '($existing + $found) | unique' 2>/dev/null || echo "$EXISTING_CPS") + fi + + # Compute content hash for stuck-detection (only for running agents) + CURRENT_HASH="" + if [[ "$PANE_STATE" == "running" ]] && [ -n "$RAW" ]; then + CURRENT_HASH=$(echo "$RAW" | tail -20 | md5_hash) + fi + + NEW_STATE="$STATE" + NEW_IDLE_SINCE="$IDLE_SINCE" + NEW_REVISION_COUNT="$REVISION_COUNT" + ACTION="none" + REASON="$PANE_REASON" + + case "$PANE_STATE" in + complete) + # Agent output ORCHESTRATOR:DONE — mark pending_evaluation so orchestrator handles it. + # run-loop does NOT verify or notify; orchestrator's background poll picks this up. + NEW_STATE="pending_evaluation" + ACTION="complete" # run-loop logs it but takes no action + ;; + waiting_approval) + NEW_STATE="waiting_approval" + ACTION="approve" + ;; + idle) + # Agent process has exited — needs restart + NEW_STATE="idle" + ACTION="kick" + REASON="agent exited (shell is foreground)" + NEW_REVISION_COUNT=$(( REVISION_COUNT + 1 )) + NEW_IDLE_SINCE=$NOW + if [ "$NEW_REVISION_COUNT" -ge 3 ]; then + NEW_STATE="escalated" + ACTION="none" + REASON="escalated after ${NEW_REVISION_COUNT} kicks — needs human attention" + fi + ;; + running) + # Clear idle_since only when transitioning from idle (agent was kicked and + # restarted). Do NOT reset for stuck — idle_since must persist across polls + # so STUCK_DURATION can accumulate and trigger escalation. + # Also update the local IDLE_SINCE so the hash-stability check below uses + # the reset value on this same poll, not the stale kick timestamp. + if [[ "$STATE" == "idle" ]]; then + NEW_IDLE_SINCE=0 + IDLE_SINCE=0 + fi + # Check if hash has been stable (agent may be stuck mid-task) + if [ -n "$CURRENT_HASH" ] && [ "$CURRENT_HASH" = "$LAST_HASH" ] && [ "$LAST_HASH" != "" ]; then + if [ "$IDLE_SINCE" = "0" ] || [ "$IDLE_SINCE" = "null" ]; then + NEW_IDLE_SINCE=$NOW + else + STUCK_DURATION=$(( NOW - IDLE_SINCE )) + if [ "$STUCK_DURATION" -gt "$IDLE_THRESHOLD" ]; then + NEW_REVISION_COUNT=$(( REVISION_COUNT + 1 )) + NEW_IDLE_SINCE=$NOW + if [ "$NEW_REVISION_COUNT" -ge 3 ]; then + NEW_STATE="escalated" + ACTION="none" + REASON="escalated after ${NEW_REVISION_COUNT} kicks — needs human attention" + else + NEW_STATE="stuck" + ACTION="kick" + REASON="output unchanged for ${STUCK_DURATION}s (threshold: ${IDLE_THRESHOLD}s)" + fi + fi + fi + else + # Only reset the idle timer when we have a valid hash comparison (pane + # capture succeeded). If CURRENT_HASH is empty (tmux capture-pane failed), + # preserve existing timers so stuck detection is not inadvertently reset. + if [ -n "$CURRENT_HASH" ]; then + NEW_STATE="running" + NEW_IDLE_SINCE=0 + fi + fi + ;; + error) + REASON="classify error: $PANE_REASON" + ;; + esac + + # Build updated agent record (ensure idle_since and revision_count are numeric) + # Use || true on each jq call so a malformed field skips this agent rather than + # aborting the entire poll cycle under set -e. + UPDATED_AGENT=$(echo "$agent" | jq \ + --arg state "$NEW_STATE" \ + --arg hash "$CURRENT_HASH" \ + --argjson now "$NOW" \ + --arg idle_since "$NEW_IDLE_SINCE" \ + --arg revision_count "$NEW_REVISION_COUNT" \ + --argjson checkpoints "$NEW_CHECKPOINTS_JSON" \ + '.state = $state + | .last_output_hash = (if $hash == "" then .last_output_hash else $hash end) + | .last_seen_at = $now + | .idle_since = ($idle_since | tonumber) + | .revision_count = ($revision_count | tonumber) + | .checkpoints = $checkpoints' 2>/dev/null) || { + echo "Warning: failed to build updated agent for window $WINDOW — keeping original" >&2 + UPDATED_AGENTS=$(echo "$UPDATED_AGENTS" | jq --argjson a "$agent" '. + [$a]') + continue + } + + UPDATED_AGENTS=$(echo "$UPDATED_AGENTS" | jq --argjson a "$UPDATED_AGENT" '. + [$a]') + + # Add action if needed + if [ "$ACTION" != "none" ]; then + ACTION_OBJ=$(jq -n \ + --arg window "$WINDOW" \ + --arg action "$ACTION" \ + --arg state "$NEW_STATE" \ + --arg worktree "$WORKTREE" \ + --arg objective "$OBJECTIVE" \ + --arg reason "$REASON" \ + '{window:$window, action:$action, state:$state, worktree:$worktree, objective:$objective, reason:$reason}') + ACTIONS=$(echo "$ACTIONS" | jq --argjson a "$ACTION_OBJ" '. + [$a]') + fi + +done <<< "$AGENTS_JSON" + +# Atomic state file update +jq --argjson agents "$UPDATED_AGENTS" \ + --argjson now "$NOW" \ + '.agents = $agents | .last_poll_at = $now' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" + +echo "$ACTIONS" diff --git a/.claude/skills/orchestrate/scripts/recycle-agent.sh b/.claude/skills/orchestrate/scripts/recycle-agent.sh new file mode 100755 index 0000000000..6d5e2fdc8f --- /dev/null +++ b/.claude/skills/orchestrate/scripts/recycle-agent.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# recycle-agent.sh — kill a tmux window and restore the worktree to its spare branch +# +# Usage: recycle-agent.sh WINDOW WORKTREE_PATH SPARE_BRANCH +# WINDOW — tmux target, e.g. autogpt1:3 +# WORKTREE_PATH — absolute path to the git worktree +# SPARE_BRANCH — branch to restore, e.g. spare/6 +# +# Stdout: one status line + +set -euo pipefail + +if [ $# -lt 3 ]; then + echo "Usage: recycle-agent.sh WINDOW WORKTREE_PATH SPARE_BRANCH" >&2 + exit 1 +fi + +WINDOW="$1" +WORKTREE_PATH="$2" +SPARE_BRANCH="$3" + +# Kill the tmux window (ignore error — may already be gone) +tmux kill-window -t "$WINDOW" 2>/dev/null || true + +# Restore to spare branch: abort any in-progress operation, then clean +git -C "$WORKTREE_PATH" rebase --abort 2>/dev/null || true +git -C "$WORKTREE_PATH" merge --abort 2>/dev/null || true +git -C "$WORKTREE_PATH" reset --hard HEAD 2>/dev/null +git -C "$WORKTREE_PATH" clean -fd 2>/dev/null +git -C "$WORKTREE_PATH" checkout "$SPARE_BRANCH" + +echo "Recycled: $(basename "$WORKTREE_PATH") → $SPARE_BRANCH (window $WINDOW closed)" diff --git a/.claude/skills/orchestrate/scripts/run-loop.sh b/.claude/skills/orchestrate/scripts/run-loop.sh new file mode 100755 index 0000000000..cfa7cf9a67 --- /dev/null +++ b/.claude/skills/orchestrate/scripts/run-loop.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# run-loop.sh — Mechanical babysitter for the agent fleet (runs in its own tmux window) +# +# Handles ONLY two things that need no intelligence: +# idle → restart claude using --resume SESSION_ID (or --continue) to restore context +# approve → auto-approve safe dialogs, press Enter on numbered-option dialogs +# +# Everything else — ORCHESTRATOR:DONE, verification, /pr-test, final evaluation, +# marking done, deciding to close windows — is the orchestrating Claude's job. +# poll-cycle.sh sets state to pending_evaluation when ORCHESTRATOR:DONE is detected; +# the orchestrator's background poll loop handles it from there. +# +# Usage: run-loop.sh +# Env: POLL_INTERVAL (default: 30), ORCHESTRATOR_STATE_FILE + +set -euo pipefail + +# Copy scripts to a stable location outside the repo so they survive branch +# checkouts (e.g. recycle-agent.sh switching spare/N back into this worktree +# would wipe .claude/skills/orchestrate/scripts if the skill only exists on the +# current branch). +_ORIGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STABLE_SCRIPTS_DIR="$HOME/.claude/orchestrator/scripts" +mkdir -p "$STABLE_SCRIPTS_DIR" +cp "$_ORIGIN_DIR"/*.sh "$STABLE_SCRIPTS_DIR/" +chmod +x "$STABLE_SCRIPTS_DIR"/*.sh +SCRIPTS_DIR="$STABLE_SCRIPTS_DIR" + +STATE_FILE="${ORCHESTRATOR_STATE_FILE:-$HOME/.claude/orchestrator-state.json}" +POLL_INTERVAL="${POLL_INTERVAL:-30}" + +# --------------------------------------------------------------------------- +# update_state WINDOW FIELD VALUE +# --------------------------------------------------------------------------- +update_state() { + local window="$1" field="$2" value="$3" + jq --arg w "$window" --arg f "$field" --arg v "$value" \ + '.agents |= map(if .window == $w then .[$f] = $v else . end)' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" +} + +update_state_int() { + local window="$1" field="$2" value="$3" + jq --arg w "$window" --arg f "$field" --argjson v "$value" \ + '.agents |= map(if .window == $w then .[$f] = $v else . end)' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" +} + +agent_field() { + jq -r --arg w "$1" --arg f "$2" \ + '.agents[] | select(.window == $w) | .[$f] // ""' \ + "$STATE_FILE" 2>/dev/null +} + +# --------------------------------------------------------------------------- +# wait_for_prompt WINDOW — wait up to 60s for Claude's ❯ prompt +# --------------------------------------------------------------------------- +wait_for_prompt() { + local window="$1" + for i in $(seq 1 60); do + local cmd pane + cmd=$(tmux display-message -t "$window" -p '#{pane_current_command}' 2>/dev/null || echo "") + pane=$(tmux capture-pane -t "$window" -p 2>/dev/null || echo "") + if echo "$pane" | grep -q "Enter to confirm"; then + tmux send-keys -t "$window" Down Enter; sleep 2; continue + fi + [[ "$cmd" == "node" ]] && echo "$pane" | grep -q "❯" && return 0 + sleep 1 + done + return 1 # timed out +} + +# --------------------------------------------------------------------------- +# handle_kick WINDOW STATE — only for idle (crashed) agents, not stuck +# --------------------------------------------------------------------------- +handle_kick() { + local window="$1" state="$2" + [[ "$state" != "idle" ]] && return # stuck agents handled by supervisor + + local worktree_path session_id + worktree_path=$(agent_field "$window" "worktree_path") + session_id=$(agent_field "$window" "session_id") + + echo "[$(date +%H:%M:%S)] KICK restart $window — agent exited, resuming session" + + # Resume the exact session so the agent retains full context — no need to re-send objective + if [ -n "$session_id" ]; then + tmux send-keys -t "$window" "cd '${worktree_path}' && claude --resume '${session_id}' --permission-mode bypassPermissions" Enter + else + tmux send-keys -t "$window" "cd '${worktree_path}' && claude --continue --permission-mode bypassPermissions" Enter + fi + + wait_for_prompt "$window" || echo "[$(date +%H:%M:%S)] KICK WARNING $window — timed out waiting for ❯" +} + +# --------------------------------------------------------------------------- +# handle_approve WINDOW — auto-approve dialogs that need no judgment +# --------------------------------------------------------------------------- +handle_approve() { + local window="$1" + local pane_tail + pane_tail=$(tmux capture-pane -t "$window" -p 2>/dev/null | tail -3 || echo "") + + # Settings error dialog at startup + if echo "$pane_tail" | grep -q "Enter to confirm"; then + echo "[$(date +%H:%M:%S)] APPROVE dialog $window — settings error" + tmux send-keys -t "$window" Down Enter + return + fi + + # Numbered-option dialog (e.g. "Do you want to make this edit?") + # ❯ is already on option 1 (Yes) — Enter confirms it + if echo "$pane_tail" | grep -qE "❯\s*1\." || echo "$pane_tail" | grep -q "Esc to cancel"; then + echo "[$(date +%H:%M:%S)] APPROVE edit $window" + tmux send-keys -t "$window" "" Enter + return + fi + + # y/n prompt for safe operations + if echo "$pane_tail" | grep -qiE "(^git |^npm |^pnpm |^poetry |^pytest|^docker |^make |^cargo |^pip |^yarn |curl .*(localhost|127\.0\.0\.1))"; then + echo "[$(date +%H:%M:%S)] APPROVE safe $window" + tmux send-keys -t "$window" "y" Enter + return + fi + + # Anything else — supervisor handles it, just log + echo "[$(date +%H:%M:%S)] APPROVE skip $window — unknown dialog, supervisor will handle" +} + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- +echo "[$(date +%H:%M:%S)] run-loop started (mechanical only, poll every ${POLL_INTERVAL}s)" +echo "[$(date +%H:%M:%S)] Supervisor: orchestrating Claude session (not a separate window)" +echo "---" + +while true; do + if ! jq -e '.active == true' "$STATE_FILE" >/dev/null 2>&1; then + echo "[$(date +%H:%M:%S)] active=false — exiting." + exit 0 + fi + + ACTIONS=$("$SCRIPTS_DIR/poll-cycle.sh" 2>/dev/null || echo "[]") + KICKED=0; DONE=0 + + while IFS= read -r action; do + [ -z "$action" ] && continue + WINDOW=$(echo "$action" | jq -r '.window // ""') + ACTION=$(echo "$action" | jq -r '.action // ""') + STATE=$(echo "$action" | jq -r '.state // ""') + + case "$ACTION" in + kick) handle_kick "$WINDOW" "$STATE" || true; KICKED=$(( KICKED + 1 )) ;; + approve) handle_approve "$WINDOW" || true ;; + complete) DONE=$(( DONE + 1 )) ;; # poll-cycle already set state=pending_evaluation; orchestrator handles + esac + done < <(echo "$ACTIONS" | jq -c '.[]' 2>/dev/null || true) + + RUNNING=$(jq '[.agents[] | select(.state | test("running|stuck|waiting_approval|idle"))] | length' \ + "$STATE_FILE" 2>/dev/null || echo 0) + + echo "[$(date +%H:%M:%S)] Poll — ${RUNNING} running ${KICKED} kicked ${DONE} recycled" + sleep "$POLL_INTERVAL" +done diff --git a/.claude/skills/orchestrate/scripts/spawn-agent.sh b/.claude/skills/orchestrate/scripts/spawn-agent.sh new file mode 100755 index 0000000000..526a32f067 --- /dev/null +++ b/.claude/skills/orchestrate/scripts/spawn-agent.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# spawn-agent.sh — create tmux window, checkout branch, launch claude, send task +# +# Usage: spawn-agent.sh SESSION WORKTREE_PATH SPARE_BRANCH NEW_BRANCH OBJECTIVE [PR_NUMBER] [STEPS...] +# SESSION — tmux session name, e.g. autogpt1 +# WORKTREE_PATH — absolute path to the git worktree +# SPARE_BRANCH — spare branch being replaced, e.g. spare/6 (saved for recycle) +# NEW_BRANCH — task branch to create, e.g. feat/my-feature +# OBJECTIVE — task description sent to the agent +# PR_NUMBER — (optional) GitHub PR number for completion verification +# STEPS... — (optional) required checkpoint names, e.g. pr-address pr-test +# +# Stdout: SESSION:WINDOW_INDEX (nothing else — callers rely on this) +# Exit non-zero on failure. + +set -euo pipefail + +if [ $# -lt 5 ]; then + echo "Usage: spawn-agent.sh SESSION WORKTREE_PATH SPARE_BRANCH NEW_BRANCH OBJECTIVE [PR_NUMBER] [STEPS...]" >&2 + exit 1 +fi + +SESSION="$1" +WORKTREE_PATH="$2" +SPARE_BRANCH="$3" +NEW_BRANCH="$4" +OBJECTIVE="$5" +PR_NUMBER="${6:-}" +STEPS=("${@:7}") +WORKTREE_NAME=$(basename "$WORKTREE_PATH") +STATE_FILE="${ORCHESTRATOR_STATE_FILE:-$HOME/.claude/orchestrator-state.json}" + +# Generate a stable session ID so this agent's Claude session can always be resumed: +# claude --resume $SESSION_ID --permission-mode bypassPermissions +SESSION_ID=$(uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())") + +# Create (or switch to) the task branch +git -C "$WORKTREE_PATH" checkout -b "$NEW_BRANCH" 2>/dev/null \ + || git -C "$WORKTREE_PATH" checkout "$NEW_BRANCH" + +# Open a new named tmux window; capture its numeric index +WIN_IDX=$(tmux new-window -t "$SESSION" -n "$WORKTREE_NAME" -P -F '#{window_index}') +WINDOW="${SESSION}:${WIN_IDX}" + +# Append the initial agent record to the state file so subsequent jq updates find it. +# This must happen before the pr_number/steps update below. +if [ -f "$STATE_FILE" ]; then + NOW=$(date +%s) + jq --arg window "$WINDOW" \ + --arg worktree "$WORKTREE_NAME" \ + --arg worktree_path "$WORKTREE_PATH" \ + --arg spare_branch "$SPARE_BRANCH" \ + --arg branch "$NEW_BRANCH" \ + --arg objective "$OBJECTIVE" \ + --arg session_id "$SESSION_ID" \ + --argjson now "$NOW" \ + '.agents += [{ + "window": $window, + "worktree": $worktree, + "worktree_path": $worktree_path, + "spare_branch": $spare_branch, + "branch": $branch, + "objective": $objective, + "session_id": $session_id, + "state": "running", + "checkpoints": [], + "last_output_hash": "", + "last_seen_at": $now, + "spawned_at": $now, + "idle_since": 0, + "revision_count": 0, + "last_rebriefed_at": 0 + }]' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" +fi + +# Store pr_number + steps in state file if provided (enables verify-complete.sh). +# The agent record was appended above so the jq select now finds it. +if [ -n "$PR_NUMBER" ] && [ -f "$STATE_FILE" ]; then + if [ "${#STEPS[@]}" -gt 0 ]; then + STEPS_JSON=$(printf '%s\n' "${STEPS[@]}" | jq -R . | jq -s .) + else + STEPS_JSON='[]' + fi + jq --arg w "$WINDOW" --arg pr "$PR_NUMBER" --argjson steps "$STEPS_JSON" \ + '.agents |= map(if .window == $w then . + {pr_number: $pr, steps: $steps, checkpoints: []} else . end)' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" +fi + +# Launch claude with a stable session ID so it can always be resumed after a crash: +# claude --resume SESSION_ID --permission-mode bypassPermissions +tmux send-keys -t "$WINDOW" "cd '${WORKTREE_PATH}' && claude --permission-mode bypassPermissions --session-id '${SESSION_ID}'" Enter + +# Wait up to 60s for claude to be fully interactive: +# both pane_current_command == 'node' AND the '❯' prompt is visible. +PROMPT_FOUND=false +for i in $(seq 1 60); do + CMD=$(tmux display-message -t "$WINDOW" -p '#{pane_current_command}' 2>/dev/null || echo "") + PANE=$(tmux capture-pane -t "$WINDOW" -p 2>/dev/null || echo "") + if echo "$PANE" | grep -q "Enter to confirm"; then + tmux send-keys -t "$WINDOW" Down Enter + sleep 2 + continue + fi + if [[ "$CMD" == "node" ]] && echo "$PANE" | grep -q "❯"; then + PROMPT_FOUND=true + break + fi + sleep 1 +done + +if ! $PROMPT_FOUND; then + echo "[spawn-agent] WARNING: timed out waiting for ❯ prompt on $WINDOW — sending objective anyway" >&2 +fi + +# Send the task. Split text and Enter — if combined, Enter can fire before the string +# is fully buffered, leaving the message stuck as "[Pasted text +N lines]" unsent. +tmux send-keys -t "$WINDOW" "${OBJECTIVE} Output each completed step as CHECKPOINT:. When ALL steps are done, output ORCHESTRATOR:DONE on its own line." +sleep 0.3 +tmux send-keys -t "$WINDOW" Enter + +# Only output the window address — nothing else (callers parse this) +echo "$WINDOW" diff --git a/.claude/skills/orchestrate/scripts/status.sh b/.claude/skills/orchestrate/scripts/status.sh new file mode 100755 index 0000000000..d1b191c05f --- /dev/null +++ b/.claude/skills/orchestrate/scripts/status.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# status.sh — print orchestrator status: state file summary + live tmux pane commands +# +# Usage: status.sh +# Reads: ~/.claude/orchestrator-state.json + +set -euo pipefail + +STATE_FILE="${ORCHESTRATOR_STATE_FILE:-$HOME/.claude/orchestrator-state.json}" + +if [ ! -f "$STATE_FILE" ] || ! jq -e '.' "$STATE_FILE" >/dev/null 2>&1; then + echo "No orchestrator state found at $STATE_FILE" + exit 0 +fi + +# Header: active status, session, thresholds, last poll +jq -r ' + "=== Orchestrator [\(if .active then "RUNNING" else "STOPPED" end)] ===", + "Session: \(.tmux_session // "unknown") | Idle threshold: \(.idle_threshold_seconds // 300)s", + "Last poll: \(if (.last_poll_at // 0) == 0 then "never" else (.last_poll_at | strftime("%H:%M:%S")) end)", + "" +' "$STATE_FILE" + +# Each agent: state, window, worktree/branch, truncated objective +AGENT_COUNT=$(jq '.agents | length' "$STATE_FILE") +if [ "$AGENT_COUNT" -eq 0 ]; then + echo " (no agents registered)" +else + jq -r ' + .agents[] | + " [\(.state | ascii_upcase)] \(.window) \(.worktree)/\(.branch)", + " \(.objective // "" | .[0:70])" + ' "$STATE_FILE" +fi + +echo "" + +# Live pane_current_command for non-done agents +while IFS= read -r WINDOW; do + [ -z "$WINDOW" ] && continue + CMD=$(tmux display-message -t "$WINDOW" -p '#{pane_current_command}' 2>/dev/null || echo "unreachable") + echo " $WINDOW live: $CMD" +done < <(jq -r '.agents[] | select(.state != "done") | .window' "$STATE_FILE" 2>/dev/null || true) diff --git a/.claude/skills/orchestrate/scripts/verify-complete.sh b/.claude/skills/orchestrate/scripts/verify-complete.sh new file mode 100644 index 0000000000..4ce6ae7eec --- /dev/null +++ b/.claude/skills/orchestrate/scripts/verify-complete.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# verify-complete.sh — verify a PR task is truly done before marking the agent done +# +# Check order matters: +# 1. Checkpoints — did the agent do all required steps? +# 2. CI complete — no pending (bots post comments AFTER their check runs, must wait) +# 3. CI passing — no failures (agent must fix before done) +# 4. spawned_at — a new CI run was triggered after agent spawned (proves real work) +# 5. Unresolved threads — checked AFTER CI so bot-posted comments are included +# 6. CHANGES_REQUESTED — checked AFTER CI so bot reviews are included +# +# Usage: verify-complete.sh WINDOW +# Exit 0 = verified complete; exit 1 = not complete (stderr has reason) + +set -euo pipefail + +WINDOW="$1" +STATE_FILE="${ORCHESTRATOR_STATE_FILE:-$HOME/.claude/orchestrator-state.json}" + +PR_NUMBER=$(jq -r --arg w "$WINDOW" '.agents[] | select(.window == $w) | .pr_number // ""' "$STATE_FILE" 2>/dev/null) +STEPS=$(jq -r --arg w "$WINDOW" '.agents[] | select(.window == $w) | .steps // [] | .[]' "$STATE_FILE" 2>/dev/null || true) +CHECKPOINTS=$(jq -r --arg w "$WINDOW" '.agents[] | select(.window == $w) | .checkpoints // [] | .[]' "$STATE_FILE" 2>/dev/null || true) +WORKTREE_PATH=$(jq -r --arg w "$WINDOW" '.agents[] | select(.window == $w) | .worktree_path // ""' "$STATE_FILE" 2>/dev/null) +BRANCH=$(jq -r --arg w "$WINDOW" '.agents[] | select(.window == $w) | .branch // ""' "$STATE_FILE" 2>/dev/null) +SPAWNED_AT=$(jq -r --arg w "$WINDOW" '.agents[] | select(.window == $w) | .spawned_at // "0"' "$STATE_FILE" 2>/dev/null || echo "0") + +# No PR number = cannot verify +if [ -z "$PR_NUMBER" ]; then + echo "NOT COMPLETE: no pr_number in state — set pr_number or mark done manually" >&2 + exit 1 +fi + +# --- Check 1: all required steps are checkpointed --- +MISSING="" +while IFS= read -r step; do + [ -z "$step" ] && continue + if ! echo "$CHECKPOINTS" | grep -qFx "$step"; then + MISSING="$MISSING $step" + fi +done <<< "$STEPS" + +if [ -n "$MISSING" ]; then + echo "NOT COMPLETE: missing checkpoints:$MISSING on PR #$PR_NUMBER" >&2 + exit 1 +fi + +# Resolve repo for all GitHub checks below +REPO=$(jq -r '.repo // ""' "$STATE_FILE" 2>/dev/null || echo "") +if [ -z "$REPO" ] && [ -n "$WORKTREE_PATH" ] && [ -d "$WORKTREE_PATH" ]; then + REPO=$(git -C "$WORKTREE_PATH" remote get-url origin 2>/dev/null \ + | sed 's|.*github\.com[:/]||; s|\.git$||' || echo "") +fi + +if [ -z "$REPO" ]; then + echo "Warning: cannot resolve repo — skipping CI/thread checks" >&2 + echo "VERIFIED: PR #$PR_NUMBER — checkpoints ✓ (CI/thread checks skipped — no repo)" + exit 0 +fi + +CI_BUCKETS=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json bucket 2>/dev/null || echo "[]") + +# --- Check 2: CI fully complete — no pending checks --- +# Pending checks MUST finish before we check threads/reviews: +# bots (Seer, Check PR Status, etc.) post comments and CHANGES_REQUESTED AFTER their CI check runs. +PENDING=$(echo "$CI_BUCKETS" | jq '[.[] | select(.bucket == "pending")] | length' 2>/dev/null || echo "0") +if [ "$PENDING" -gt 0 ]; then + PENDING_NAMES=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json bucket,name 2>/dev/null \ + | jq -r '[.[] | select(.bucket == "pending") | .name] | join(", ")' 2>/dev/null || echo "unknown") + echo "NOT COMPLETE: $PENDING CI checks still pending on PR #$PR_NUMBER ($PENDING_NAMES)" >&2 + exit 1 +fi + +# --- Check 3: CI passing — no failures --- +FAILING=$(echo "$CI_BUCKETS" | jq '[.[] | select(.bucket == "fail")] | length' 2>/dev/null || echo "0") +if [ "$FAILING" -gt 0 ]; then + FAILING_NAMES=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json bucket,name 2>/dev/null \ + | jq -r '[.[] | select(.bucket == "fail") | .name] | join(", ")' 2>/dev/null || echo "unknown") + echo "NOT COMPLETE: $FAILING failing CI checks on PR #$PR_NUMBER ($FAILING_NAMES)" >&2 + exit 1 +fi + +# --- Check 4: a new CI run was triggered AFTER the agent spawned --- +if [ -n "$BRANCH" ] && [ "${SPAWNED_AT:-0}" -gt 0 ]; then + LATEST_RUN_AT=$(gh run list --repo "$REPO" --branch "$BRANCH" \ + --json createdAt --limit 1 2>/dev/null | jq -r '.[0].createdAt // ""') + if [ -n "$LATEST_RUN_AT" ]; then + if date --version >/dev/null 2>&1; then + LATEST_RUN_EPOCH=$(date -d "$LATEST_RUN_AT" "+%s" 2>/dev/null || echo "0") + else + LATEST_RUN_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LATEST_RUN_AT" "+%s" 2>/dev/null || echo "0") + fi + if [ "$LATEST_RUN_EPOCH" -le "$SPAWNED_AT" ]; then + echo "NOT COMPLETE: latest CI run on $BRANCH predates agent spawn — agent may not have pushed yet" >&2 + exit 1 + fi + fi +fi + +OWNER=$(echo "$REPO" | cut -d/ -f1) +REPONAME=$(echo "$REPO" | cut -d/ -f2) + +# --- Check 5: no unresolved review threads (checked AFTER CI — bots post after their check) --- +UNRESOLVED=$(gh api graphql -f query=" + { repository(owner: \"${OWNER}\", name: \"${REPONAME}\") { + pullRequest(number: ${PR_NUMBER}) { + reviewThreads(first: 50) { nodes { isResolved } } + } + } + } +" --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' 2>/dev/null || echo "0") + +if [ "$UNRESOLVED" -gt 0 ]; then + echo "NOT COMPLETE: $UNRESOLVED unresolved review threads on PR #$PR_NUMBER" >&2 + exit 1 +fi + +# --- Check 6: no CHANGES_REQUESTED (checked AFTER CI — bots post reviews after their check) --- +CHANGES_REQUESTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json reviews --jq '[.reviews[] | select(.state == "CHANGES_REQUESTED")] | length' 2>/dev/null || echo "0") + +if [ "$CHANGES_REQUESTED" -gt 0 ]; then + REQUESTERS=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json reviews --jq '[.reviews[] | select(.state == "CHANGES_REQUESTED") | .author.login] | join(", ")' 2>/dev/null || echo "unknown") + echo "NOT COMPLETE: CHANGES_REQUESTED from ${REQUESTERS} on PR #$PR_NUMBER" >&2 + exit 1 +fi + +echo "VERIFIED: PR #$PR_NUMBER — checkpoints ✓, CI complete + green, 0 unresolved threads, no CHANGES_REQUESTED" +exit 0 diff --git a/.claude/skills/pr-address/SKILL.md b/.claude/skills/pr-address/SKILL.md index 4c6ab81e58..9a9c89e0ec 100644 --- a/.claude/skills/pr-address/SKILL.md +++ b/.claude/skills/pr-address/SKILL.md @@ -90,10 +90,12 @@ Address comments **one at a time**: fix → commit → push → inline reply → 2. Commit and push the fix 3. Reply **inline** (not as a new top-level comment) referencing the fixing commit — this is what resolves the conversation for bot reviewers (coderabbitai, sentry): +Use a **markdown commit link** so GitHub renders it as a clickable reference. Get the full SHA with `git rev-parse HEAD` after committing: + | Comment type | How to reply | |---|---| -| Inline review (`pulls/{N}/comments`) | `gh api repos/Significant-Gravitas/AutoGPT/pulls/{N}/comments/{ID}/replies -f body="🤖 Fixed in : "` | -| Conversation (`issues/{N}/comments`) | `gh api repos/Significant-Gravitas/AutoGPT/issues/{N}/comments -f body="🤖 Fixed in : "` | +| Inline review (`pulls/{N}/comments`) | `gh api repos/Significant-Gravitas/AutoGPT/pulls/{N}/comments/{ID}/replies -f body="🤖 Fixed in [abc1234](https://github.com/Significant-Gravitas/AutoGPT/commit/FULL_SHA): "` | +| Conversation (`issues/{N}/comments`) | `gh api repos/Significant-Gravitas/AutoGPT/issues/{N}/comments -f body="🤖 Fixed in [abc1234](https://github.com/Significant-Gravitas/AutoGPT/commit/FULL_SHA): "` | ## Codecov coverage diff --git a/.claude/skills/pr-test/SKILL.md b/.claude/skills/pr-test/SKILL.md index b915cc55ab..f11feda332 100644 --- a/.claude/skills/pr-test/SKILL.md +++ b/.claude/skills/pr-test/SKILL.md @@ -547,6 +547,8 @@ Upload screenshots to the PR using the GitHub Git API (no local git operations **This step is MANDATORY. Every test run MUST post a PR comment with screenshots. No exceptions.** +**CRITICAL — NEVER post a bare directory link like `https://github.com/.../tree/...`.** Every screenshot MUST appear as `![name](raw_url)` inline in the PR comment so reviewers can see them without clicking any links. After posting, the verification step below greps the comment for `![` tags and exits 1 if none are found — the test run is considered incomplete until this passes. + ```bash # Upload screenshots via GitHub Git API (creates blobs, tree, commit, and ref remotely) REPO="Significant-Gravitas/AutoGPT" @@ -584,15 +586,27 @@ TREE_JSON+=']' # Step 2: Create tree, commit, and branch ref TREE_SHA=$(echo "$TREE_JSON" | jq -c '{tree: .}' | gh api "repos/${REPO}/git/trees" --input - --jq '.sha') -COMMIT_SHA=$(gh api "repos/${REPO}/git/commits" \ - -f message="test: add E2E test screenshots for PR #${PR_NUMBER}" \ - -f tree="$TREE_SHA" \ - --jq '.sha') + +# Resolve parent commit so screenshots are chained, not orphan root commits +PARENT_SHA=$(gh api "repos/${REPO}/git/refs/heads/${SCREENSHOTS_BRANCH}" --jq '.object.sha' 2>/dev/null || echo "") +if [ -n "$PARENT_SHA" ]; then + COMMIT_SHA=$(gh api "repos/${REPO}/git/commits" \ + -f message="test: add E2E test screenshots for PR #${PR_NUMBER}" \ + -f tree="$TREE_SHA" \ + -f "parents[]=$PARENT_SHA" \ + --jq '.sha') +else + COMMIT_SHA=$(gh api "repos/${REPO}/git/commits" \ + -f message="test: add E2E test screenshots for PR #${PR_NUMBER}" \ + -f tree="$TREE_SHA" \ + --jq '.sha') +fi + gh api "repos/${REPO}/git/refs" \ -f ref="refs/heads/${SCREENSHOTS_BRANCH}" \ -f sha="$COMMIT_SHA" 2>/dev/null \ || gh api "repos/${REPO}/git/refs/heads/${SCREENSHOTS_BRANCH}" \ - -X PATCH -f sha="$COMMIT_SHA" -f force=true + -X PATCH -f sha="$COMMIT_SHA" -F force=true ``` Then post the comment with **inline images AND explanations for each screenshot**: @@ -658,6 +672,15 @@ INNEREOF gh api "repos/${REPO}/issues/$PR_NUMBER/comments" -F body=@"$COMMENT_FILE" rm -f "$COMMENT_FILE" + +# Verify the posted comment contains inline images — exit 1 if none found +# Use separate --paginate + jq pipe: --jq applies per-page, not to the full list +LAST_COMMENT=$(gh api "repos/${REPO}/issues/$PR_NUMBER/comments" --paginate 2>/dev/null | jq -r '.[-1].body // ""') +if ! echo "$LAST_COMMENT" | grep -q '!\['; then + echo "ERROR: Posted comment contains no inline images (![). Bare directory links are not acceptable." >&2 + exit 1 +fi +echo "✓ Inline images verified in posted comment" ``` **The PR comment MUST include:** @@ -667,6 +690,103 @@ rm -f "$COMMENT_FILE" This approach uses the GitHub Git API to create blobs, trees, commits, and refs entirely server-side. No local `git checkout` or `git push` — safe for worktrees and won't interfere with the PR branch. +## Step 8: Evaluate and post a formal PR review + +After the test comment is posted, evaluate whether the run was thorough enough to make a merge decision, then post a formal GitHub review (approve or request changes). **This step is mandatory — every test run MUST end with a formal review decision.** + +### Evaluation criteria + +Re-read the PR description: +```bash +gh pr view "$PR_NUMBER" --json body --jq '.body' --repo "$REPO" +``` + +Score the run against each criterion: + +| Criterion | Pass condition | +|-----------|---------------| +| **Coverage** | Every feature/change described in the PR has at least one test scenario | +| **All scenarios pass** | No FAIL rows in the results table | +| **Negative tests** | At least one failure-path test per feature (invalid input, unauthorized, edge case) | +| **Before/after evidence** | Every state-changing API call has before/after values logged | +| **Screenshots are meaningful** | Screenshots show the actual state change, not just a loading spinner or blank page | +| **No regressions** | Existing core flows (login, agent create/run) still work | + +### Decision logic + +``` +ALL criteria pass → APPROVE +Any scenario FAIL or missing PR feature → REQUEST_CHANGES (list gaps) +Evidence weak (no before/after, vague shots) → REQUEST_CHANGES (list what's missing) +``` + +### Post the review + +```bash +REVIEW_FILE=$(mktemp) + +# Count results +PASS_COUNT=$(echo "$TEST_RESULTS_TABLE" | grep -c "PASS" || true) +FAIL_COUNT=$(echo "$TEST_RESULTS_TABLE" | grep -c "FAIL" || true) +TOTAL=$(( PASS_COUNT + FAIL_COUNT )) + +# List any coverage gaps found during evaluation (populate this array as you assess) +# e.g. COVERAGE_GAPS=("PR claims to add X but no test covers it") +COVERAGE_GAPS=() +``` + +**If APPROVING** — all criteria met, zero failures, full coverage: + +```bash +cat > "$REVIEW_FILE" < "$REVIEW_FILE" </dev/null + +# For a component at src/app/(platform)/library/components/AgentCard/AgentCard.tsx +ls src/app/\(platform\)/library/components/AgentCard/__tests__/ 2>/dev/null +``` + +Note which targets have no tests (need new files) vs which have tests that need updating. + +## Step 4: Identify API endpoints used + +For each test target, find which API hooks are used: + +```bash +# Find generated API hook imports in the changed files +grep -rn 'from.*__generated__/endpoints' src/app/\(platform\)/library/ +grep -rn 'use[A-Z].*V[12]' src/app/\(platform\)/library/ +``` + +For each API hook found, locate the corresponding MSW handler: + +```bash +# If the page uses useGetV2ListLibraryAgents, find its MSW handlers +grep -rn 'getGetV2ListLibraryAgents.*Handler' src/app/api/__generated__/endpoints/library/library.msw.ts +``` + +List every MSW handler you will need (200 for happy path, 4xx for error paths). + +## Step 5: Write the test plan + +Before writing code, output a plan as a numbered list: + +``` +Test plan for [branch name]: + +1. src/app/(platform)/library/__tests__/main.test.tsx (NEW) + - Renders page with agent list (MSW 200) + - Shows loading state + - Shows error state (MSW 422) + - Handles empty agent list + +2. src/app/(platform)/library/__tests__/search.test.tsx (NEW) + - Filters agents by search query + - Shows no results message + - Clears search + +3. src/app/(platform)/library/components/AgentCard/__tests__/AgentCard.test.tsx (UPDATE) + - Add test for new "duplicate" action +``` + +Present this plan to the user. Wait for confirmation before proceeding. If the user has feedback, adjust the plan. + +## Step 6: Write the tests + +For each test file in the plan, follow these conventions: + +### File structure + +```tsx +import { render, screen, waitFor } from "@/tests/integrations/test-utils"; +import { server } from "@/mocks/mock-server"; +// Import MSW handlers for endpoints the page uses +import { + getGetV2ListLibraryAgentsMockHandler200, + getGetV2ListLibraryAgentsMockHandler422, +} from "@/app/api/__generated__/endpoints/library/library.msw"; +// Import the component under test +import LibraryPage from "../page"; + +describe("LibraryPage", () => { + test("renders agent list from API", async () => { + server.use(getGetV2ListLibraryAgentsMockHandler200()); + + render(); + + expect(await screen.findByText(/my agents/i)).toBeDefined(); + }); + + test("shows error state on API failure", async () => { + server.use(getGetV2ListLibraryAgentsMockHandler422()); + + render(); + + expect(await screen.findByText(/error/i)).toBeDefined(); + }); +}); +``` + +### Rules + +- Use `render()` from `@/tests/integrations/test-utils` (NOT from `@testing-library/react` directly) +- Use `server.use()` to set up MSW handlers BEFORE rendering +- Use `findBy*` (async) for elements that appear after data fetching — NOT `getBy*` +- Use `getBy*` only for elements that are immediately present in the DOM +- Use `screen` queries — do NOT destructure from `render()` +- Use `waitFor` when asserting side effects or state changes after interactions +- Import `fireEvent` or `userEvent` from the test-utils for interactions +- Do NOT mock internal hooks or functions — mock at the API boundary via MSW +- Do NOT use `act()` manually — `render` and `fireEvent` handle it +- Keep tests focused: one behavior per test +- Use descriptive test names that read like sentences + +### Test location + +``` +# For pages: __tests__/ next to page.tsx +src/app/(platform)/library/__tests__/main.test.tsx + +# For complex standalone components: __tests__/ inside component folder +src/app/(platform)/library/components/AgentCard/__tests__/AgentCard.test.tsx + +# For pure helpers: co-located .test.ts +src/app/(platform)/library/helpers.test.ts +``` + +### Custom MSW overrides + +When the auto-generated faker data is not enough, override with specific data: + +```tsx +import { http, HttpResponse } from "msw"; + +server.use( + http.get("http://localhost:3000/api/proxy/api/v2/library/agents", () => { + return HttpResponse.json({ + agents: [ + { id: "1", name: "Test Agent", description: "A test agent" }, + ], + pagination: { total_items: 1, total_pages: 1, page: 1, page_size: 10 }, + }); + }), +); +``` + +Use the proxy URL pattern: `http://localhost:3000/api/proxy/api/v{version}/{path}` — this matches the MSW base URL configured in `orval.config.ts`. + +## Step 7: Run and verify + +After writing all tests: + +```bash +cd autogpt_platform/frontend +pnpm test:unit --reporter=verbose +``` + +If tests fail: +1. Read the error output carefully +2. Fix the test (not the source code, unless there is a genuine bug) +3. Re-run until all pass + +Then run the full checks: + +```bash +pnpm format +pnpm lint +pnpm types +``` diff --git a/.github/workflows/platform-fullstack-ci.yml b/.github/workflows/platform-fullstack-ci.yml index fc772171b1..5020f8aa2e 100644 --- a/.github/workflows/platform-fullstack-ci.yml +++ b/.github/workflows/platform-fullstack-ci.yml @@ -179,21 +179,30 @@ jobs: pip install pyyaml # Resolve extends and generate a flat compose file that bake can understand + export NEXT_PUBLIC_SOURCEMAPS NEXT_PUBLIC_PW_TEST docker compose -f docker-compose.yml config > docker-compose.resolved.yml + # Ensure NEXT_PUBLIC_SOURCEMAPS is in resolved compose + # (docker compose config on some versions drops this arg) + if ! grep -q "NEXT_PUBLIC_SOURCEMAPS" docker-compose.resolved.yml; then + echo "Injecting NEXT_PUBLIC_SOURCEMAPS into resolved compose (docker compose config dropped it)" + sed -i '/NEXT_PUBLIC_PW_TEST/a\ NEXT_PUBLIC_SOURCEMAPS: "true"' docker-compose.resolved.yml + fi + # Add cache configuration to the resolved compose file python ../.github/workflows/scripts/docker-ci-fix-compose-build-cache.py \ --source docker-compose.resolved.yml \ --cache-from "type=gha" \ --cache-to "type=gha,mode=max" \ --backend-hash "${{ hashFiles('autogpt_platform/backend/Dockerfile', 'autogpt_platform/backend/poetry.lock', 'autogpt_platform/backend/backend/**') }}" \ - --frontend-hash "${{ hashFiles('autogpt_platform/frontend/Dockerfile', 'autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/src/**') }}" \ + --frontend-hash "${{ hashFiles('autogpt_platform/frontend/Dockerfile', 'autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/src/**') }}-sourcemaps" \ --git-ref "${{ github.ref }}" # Build with bake using the resolved compose file (now includes cache config) docker buildx bake --allow=fs.read=.. -f docker-compose.resolved.yml --load env: NEXT_PUBLIC_PW_TEST: true + NEXT_PUBLIC_SOURCEMAPS: true - name: Set up tests - Cache E2E test data id: e2e-data-cache @@ -279,6 +288,11 @@ jobs: cache: "pnpm" cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml + - name: Copy source maps from Docker for E2E coverage + run: | + FRONTEND_CONTAINER=$(docker compose -f ../docker-compose.resolved.yml ps -q frontend) + docker cp "$FRONTEND_CONTAINER":/app/.next/static .next-static-coverage + - name: Set up tests - Install dependencies run: pnpm install --frozen-lockfile @@ -289,6 +303,15 @@ jobs: run: pnpm test:no-build continue-on-error: false + - name: Upload E2E coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: platform-frontend-e2e + files: ./autogpt_platform/frontend/coverage/e2e/cobertura-coverage.xml + disable_search: true + - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000000..75867a7f50 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,36 @@ +title = "AutoGPT Gitleaks Config" + +[extend] +useDefault = true + +[allowlist] +description = "Global allowlist" +paths = [ + # Template/example env files (no real secrets) + '''\.env\.(default|example|template)$''', + # Lock files + '''pnpm-lock\.yaml$''', + '''poetry\.lock$''', + # Secrets baseline + '''\.secrets\.baseline$''', + # Build artifacts and caches (should not be committed) + '''__pycache__/''', + '''classic/frontend/build/''', + # Docker dev setup (local dev JWTs/keys only) + '''autogpt_platform/db/docker/''', + # Load test configs (dev JWTs) + '''load-tests/configs/''', + # Test files with fake/fixture keys (_test.py, test_*.py, conftest.py) + '''(_test|test_.*|conftest)\.py$''', + # Documentation (only contains placeholder keys in curl/API examples) + '''docs/.*\.md$''', + # Firebase config (public API keys by design) + '''google-services\.json$''', + '''classic/frontend/(lib|web)/''', +] +# CI test-only encryption key (marked DO NOT USE IN PRODUCTION) +regexes = [ + '''dvziYgz0KSK8FENhju0ZYi8''', + # LLM model name enum values falsely flagged as API keys + '''Llama-\d.*Instruct''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9dc1951992..b5527825ac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,9 +23,15 @@ repos: - id: detect-secrets name: Detect secrets description: Detects high entropy strings that are likely to be passwords. + args: ["--baseline", ".secrets.baseline"] files: ^autogpt_platform/ - exclude: pnpm-lock\.yaml$ - stages: [pre-push] + exclude: (pnpm-lock\.yaml|\.env\.(default|example|template))$ + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.3 + hooks: + - id: gitleaks + name: Detect secrets (gitleaks) - repo: local # For proper type checking, all dependencies need to be up-to-date. diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000000..4b3deeb6b5 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,467 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + }, + { + "path": "detect_secrets.filters.regex.should_exclude_file", + "pattern": [ + "\\.env$", + "pnpm-lock\\.yaml$", + "\\.env\\.(default|example|template)$", + "__pycache__", + "_test\\.py$", + "test_.*\\.py$", + "conftest\\.py$", + "poetry\\.lock$", + "node_modules" + ] + } + ], + "results": { + "autogpt_platform/backend/backend/api/external/v1/integrations.py": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/backend/backend/api/external/v1/integrations.py", + "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", + "is_verified": false, + "line_number": 289 + } + ], + "autogpt_platform/backend/backend/blocks/airtable/_config.py": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/backend/backend/blocks/airtable/_config.py", + "hashed_secret": "57e168b03afb7c1ee3cdc4ee3db2fe1cc6e0df26", + "is_verified": false, + "line_number": 29 + } + ], + "autogpt_platform/backend/backend/blocks/dataforseo/_config.py": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/backend/backend/blocks/dataforseo/_config.py", + "hashed_secret": "32ce93887331fa5d192f2876ea15ec000c7d58b8", + "is_verified": false, + "line_number": 12 + } + ], + "autogpt_platform/backend/backend/blocks/github/checks.py": [ + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/github/checks.py", + "hashed_secret": "8ac6f92737d8586790519c5d7bfb4d2eb172c238", + "is_verified": false, + "line_number": 108 + } + ], + "autogpt_platform/backend/backend/blocks/github/ci.py": [ + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/github/ci.py", + "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", + "is_verified": false, + "line_number": 123 + } + ], + "autogpt_platform/backend/backend/blocks/github/example_payloads/pull_request.synchronize.json": [ + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/github/example_payloads/pull_request.synchronize.json", + "hashed_secret": "f96896dafced7387dcd22343b8ea29d3d2c65663", + "is_verified": false, + "line_number": 42 + }, + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/github/example_payloads/pull_request.synchronize.json", + "hashed_secret": "b80a94d5e70bedf4f5f89d2f5a5255cc9492d12e", + "is_verified": false, + "line_number": 193 + }, + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/github/example_payloads/pull_request.synchronize.json", + "hashed_secret": "75b17e517fe1b3136394f6bec80c4f892da75e42", + "is_verified": false, + "line_number": 344 + }, + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/github/example_payloads/pull_request.synchronize.json", + "hashed_secret": "b0bfb5e4e2394e7f8906e5ed1dffd88b2bc89dd5", + "is_verified": false, + "line_number": 534 + } + ], + "autogpt_platform/backend/backend/blocks/github/statuses.py": [ + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/github/statuses.py", + "hashed_secret": "8ac6f92737d8586790519c5d7bfb4d2eb172c238", + "is_verified": false, + "line_number": 85 + } + ], + "autogpt_platform/backend/backend/blocks/google/docs.py": [ + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/google/docs.py", + "hashed_secret": "c95da0c6696342c867ef0c8258d2f74d20fd94d4", + "is_verified": false, + "line_number": 203 + } + ], + "autogpt_platform/backend/backend/blocks/google/sheets.py": [ + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/google/sheets.py", + "hashed_secret": "bd5a04fa3667e693edc13239b6d310c5c7a8564b", + "is_verified": false, + "line_number": 57 + } + ], + "autogpt_platform/backend/backend/blocks/linear/_config.py": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/backend/backend/blocks/linear/_config.py", + "hashed_secret": "b37f020f42d6d613b6ce30103e4d408c4499b3bb", + "is_verified": false, + "line_number": 53 + } + ], + "autogpt_platform/backend/backend/blocks/medium.py": [ + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/medium.py", + "hashed_secret": "ff998abc1ce6d8f01a675fa197368e44c8916e9c", + "is_verified": false, + "line_number": 131 + } + ], + "autogpt_platform/backend/backend/blocks/replicate/replicate_block.py": [ + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/replicate/replicate_block.py", + "hashed_secret": "8bbdd6f26368f58ea4011d13d7f763cb662e66f0", + "is_verified": false, + "line_number": 55 + } + ], + "autogpt_platform/backend/backend/blocks/slant3d/webhook.py": [ + { + "type": "Hex High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/slant3d/webhook.py", + "hashed_secret": "36263c76947443b2f6e6b78153967ac4a7da99f9", + "is_verified": false, + "line_number": 100 + } + ], + "autogpt_platform/backend/backend/blocks/talking_head.py": [ + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/backend/backend/blocks/talking_head.py", + "hashed_secret": "44ce2d66222529eea4a32932823466fc0601c799", + "is_verified": false, + "line_number": 113 + } + ], + "autogpt_platform/backend/backend/blocks/wordpress/_config.py": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/backend/backend/blocks/wordpress/_config.py", + "hashed_secret": "e62679512436161b78e8a8d68c8829c2a1031ccb", + "is_verified": false, + "line_number": 17 + } + ], + "autogpt_platform/backend/backend/util/cache.py": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/backend/backend/util/cache.py", + "hashed_secret": "37f0c918c3fa47ca4a70e42037f9f123fdfbc75b", + "is_verified": false, + "line_number": 449 + } + ], + "autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts", + "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", + "is_verified": false, + "line_number": 6 + } + ], + "autogpt_platform/frontend/src/app/(platform)/dictionaries/en.json": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/dictionaries/en.json", + "hashed_secret": "8be3c943b1609fffbfc51aad666d0a04adf83c9d", + "is_verified": false, + "line_number": 5 + } + ], + "autogpt_platform/frontend/src/app/(platform)/dictionaries/es.json": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/dictionaries/es.json", + "hashed_secret": "5a6d1c612954979ea99ee33dbb2d231b00f6ac0a", + "is_verified": false, + "line_number": 5 + } + ], + "autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/helpers.ts": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/helpers.ts", + "hashed_secret": "cf678cab87dc1f7d1b95b964f15375e088461679", + "is_verified": false, + "line_number": 6 + }, + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/helpers.ts", + "hashed_secret": "f72cbb45464d487064610c5411c576ca4019d380", + "is_verified": false, + "line_number": 8 + } + ], + "autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/helpers.ts": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/helpers.ts", + "hashed_secret": "cf678cab87dc1f7d1b95b964f15375e088461679", + "is_verified": false, + "line_number": 5 + }, + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/helpers.ts", + "hashed_secret": "f72cbb45464d487064610c5411c576ca4019d380", + "is_verified": false, + "line_number": 7 + } + ], + "autogpt_platform/frontend/src/app/(platform)/profile/(user)/integrations/page.tsx": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/profile/(user)/integrations/page.tsx", + "hashed_secret": "cf678cab87dc1f7d1b95b964f15375e088461679", + "is_verified": false, + "line_number": 192 + }, + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/app/(platform)/profile/(user)/integrations/page.tsx", + "hashed_secret": "86275db852204937bbdbdebe5fabe8536e030ab6", + "is_verified": false, + "line_number": 193 + } + ], + "autogpt_platform/frontend/src/components/contextual/CredentialsInput/helpers.ts": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/components/contextual/CredentialsInput/helpers.ts", + "hashed_secret": "47acd2028cf81b5da88ddeedb2aea4eca4b71fbd", + "is_verified": false, + "line_number": 102 + }, + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/components/contextual/CredentialsInput/helpers.ts", + "hashed_secret": "8be3c943b1609fffbfc51aad666d0a04adf83c9d", + "is_verified": false, + "line_number": 103 + } + ], + "autogpt_platform/frontend/src/lib/autogpt-server-api/utils.ts": [ + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/frontend/src/lib/autogpt-server-api/utils.ts", + "hashed_secret": "9c486c92f1a7420e1045c7ad963fbb7ba3621025", + "is_verified": false, + "line_number": 73 + }, + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/frontend/src/lib/autogpt-server-api/utils.ts", + "hashed_secret": "9277508c7a6effc8fb59163efbfada189e35425c", + "is_verified": false, + "line_number": 75 + }, + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/frontend/src/lib/autogpt-server-api/utils.ts", + "hashed_secret": "8dc7e2cb1d0935897d541bf5facab389b8a50340", + "is_verified": false, + "line_number": 77 + }, + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/frontend/src/lib/autogpt-server-api/utils.ts", + "hashed_secret": "79a26ad48775944299be6aaf9fb1d5302c1ed75b", + "is_verified": false, + "line_number": 79 + }, + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/frontend/src/lib/autogpt-server-api/utils.ts", + "hashed_secret": "a3b62b44500a1612e48d4cab8294df81561b3b1a", + "is_verified": false, + "line_number": 81 + }, + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/frontend/src/lib/autogpt-server-api/utils.ts", + "hashed_secret": "a58979bd0b21ef4f50417d001008e60dd7a85c64", + "is_verified": false, + "line_number": 83 + }, + { + "type": "Base64 High Entropy String", + "filename": "autogpt_platform/frontend/src/lib/autogpt-server-api/utils.ts", + "hashed_secret": "6cb6e075f8e8c7c850f9d128d6608e5dbe209a79", + "is_verified": false, + "line_number": 85 + } + ], + "autogpt_platform/frontend/src/lib/constants.ts": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/lib/constants.ts", + "hashed_secret": "27b924db06a28cc755fb07c54f0fddc30659fe4d", + "is_verified": false, + "line_number": 10 + } + ], + "autogpt_platform/frontend/src/tests/credentials/index.ts": [ + { + "type": "Secret Keyword", + "filename": "autogpt_platform/frontend/src/tests/credentials/index.ts", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 4 + } + ] + }, + "generated_at": "2026-04-02T13:10:54Z" +} diff --git a/AGENTS.md b/AGENTS.md index f88741ae3a..d0b325167c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference: - Regenerate with `pnpm generate:api` - Pattern: `use{Method}{Version}{OperationName}` 4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only -5. **Testing**: Add Storybook stories for new components, Playwright for E2E +5. **Testing**: Integration tests (Vitest + RTL + MSW) are the default (~90%, page-level). Playwright for E2E critical flows. Storybook for design system components. See `autogpt_platform/frontend/TESTING.md` 6. **Code conventions**: Function declarations (not arrow functions) for components/handlers - Component props should be `interface Props { ... }` (not exported) unless the interface needs to be used outside the component @@ -47,7 +47,9 @@ See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference: ## Testing - Backend: `poetry run test` (runs pytest with a docker based postgres + prisma). -- Frontend: `pnpm test` or `pnpm test-ui` for Playwright tests. See `docs/content/platform/contributing/tests.md` for tips. +- Frontend integration tests: `pnpm test:unit` (Vitest + RTL + MSW, primary testing approach). +- Frontend E2E tests: `pnpm test` or `pnpm test-ui` for Playwright tests. +- See `autogpt_platform/frontend/TESTING.md` for the full testing strategy. Always run the relevant linters and tests before committing. Use conventional commit messages for all commits (e.g. `feat(backend): add API`). diff --git a/autogpt_platform/backend/backend/api/features/chat/routes.py b/autogpt_platform/backend/backend/api/features/chat/routes.py index b4f876aea4..083ad586f9 100644 --- a/autogpt_platform/backend/backend/api/features/chat/routes.py +++ b/autogpt_platform/backend/backend/api/features/chat/routes.py @@ -15,7 +15,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from backend.copilot import service as chat_service from backend.copilot import stream_registry -from backend.copilot.config import ChatConfig +from backend.copilot.config import ChatConfig, CopilotMode +from backend.copilot.db import get_chat_messages_paginated from backend.copilot.executor.utils import enqueue_cancel_task, enqueue_copilot_turn from backend.copilot.model import ( ChatMessage, @@ -111,6 +112,11 @@ class StreamChatRequest(BaseModel): file_ids: list[str] | None = Field( default=None, max_length=20 ) # Workspace file IDs attached to this message + mode: CopilotMode | None = Field( + default=None, + description="Autopilot mode: 'fast' for baseline LLM, 'extended_thinking' for Claude Agent SDK. " + "If None, uses the server default (extended_thinking).", + ) class CreateSessionRequest(BaseModel): @@ -150,6 +156,8 @@ class SessionDetailResponse(BaseModel): user_id: str | None messages: list[dict] active_stream: ActiveStreamInfo | None = None # Present if stream is still active + has_more_messages: bool = False + oldest_sequence: int | None = None total_prompt_tokens: int = 0 total_completion_tokens: int = 0 metadata: ChatSessionMetadata = ChatSessionMetadata() @@ -389,60 +397,78 @@ async def update_session_title_route( async def get_session( session_id: str, user_id: Annotated[str, Security(auth.get_user_id)], + limit: int = Query(default=50, ge=1, le=200), + before_sequence: int | None = Query(default=None, ge=0), ) -> SessionDetailResponse: """ Retrieve the details of a specific chat session. - Looks up a chat session by ID for the given user (if authenticated) and returns all session data including messages. - If there's an active stream for this session, returns active_stream info for reconnection. + Supports cursor-based pagination via ``limit`` and ``before_sequence``. + When no pagination params are provided, returns the most recent messages. Args: session_id: The unique identifier for the desired chat session. - user_id: The optional authenticated user ID, or None for anonymous access. + user_id: The authenticated user's ID. + limit: Maximum number of messages to return (1-200, default 50). + before_sequence: Return messages with sequence < this value (cursor). Returns: - SessionDetailResponse: Details for the requested session, including active_stream info if applicable. - + SessionDetailResponse: Details for the requested session, including + active_stream info and pagination metadata. """ - session = await get_chat_session(session_id, user_id) - if not session: + page = await get_chat_messages_paginated( + session_id, limit, before_sequence, user_id=user_id + ) + if page is None: raise NotFoundError(f"Session {session_id} not found.") + messages = [message.model_dump() for message in page.messages] - messages = [message.model_dump() for message in session.messages] - - # Check if there's an active stream for this session + # Only check active stream on initial load (not on "load more" requests) active_stream_info = None - active_session, last_message_id = await stream_registry.get_active_session( - session_id, user_id - ) - logger.info( - f"[GET_SESSION] session={session_id}, active_session={active_session is not None}, " - f"msg_count={len(messages)}, last_role={messages[-1].get('role') if messages else 'none'}" - ) - if active_session: - # Keep the assistant message (including tool_calls) so the frontend can - # render the correct tool UI (e.g. CreateAgent with mini game). - # convertChatSessionToUiMessages handles isComplete=false by setting - # tool parts without output to state "input-available". - active_stream_info = ActiveStreamInfo( - turn_id=active_session.turn_id, - last_message_id=last_message_id, + if before_sequence is None: + active_session, last_message_id = await stream_registry.get_active_session( + session_id, user_id + ) + logger.info( + f"[GET_SESSION] session={session_id}, active_session={active_session is not None}, " + f"msg_count={len(messages)}, last_role={messages[-1].get('role') if messages else 'none'}" + ) + if active_session: + active_stream_info = ActiveStreamInfo( + turn_id=active_session.turn_id, + last_message_id=last_message_id, + ) + + # Skip session metadata on "load more" — frontend only needs messages + if before_sequence is not None: + return SessionDetailResponse( + id=page.session.session_id, + created_at=page.session.started_at.isoformat(), + updated_at=page.session.updated_at.isoformat(), + user_id=page.session.user_id or None, + messages=messages, + active_stream=None, + has_more_messages=page.has_more, + oldest_sequence=page.oldest_sequence, + total_prompt_tokens=0, + total_completion_tokens=0, ) - # Sum token usage from session - total_prompt = sum(u.prompt_tokens for u in session.usage) - total_completion = sum(u.completion_tokens for u in session.usage) + total_prompt = sum(u.prompt_tokens for u in page.session.usage) + total_completion = sum(u.completion_tokens for u in page.session.usage) return SessionDetailResponse( - id=session.session_id, - created_at=session.started_at.isoformat(), - updated_at=session.updated_at.isoformat(), - user_id=session.user_id or None, + id=page.session.session_id, + created_at=page.session.started_at.isoformat(), + updated_at=page.session.updated_at.isoformat(), + user_id=page.session.user_id or None, messages=messages, active_stream=active_stream_info, + has_more_messages=page.has_more, + oldest_sequence=page.oldest_sequence, total_prompt_tokens=total_prompt, total_completion_tokens=total_completion, - metadata=session.metadata, + metadata=page.session.metadata, ) @@ -843,6 +869,7 @@ async def stream_chat_post( file_ids=sanitized_file_ids, organization_id=ctx.org_id, team_id=ctx.team_id, + mode=request.mode, ) setup_time = (time.perf_counter() - stream_start_time) * 1000 diff --git a/autogpt_platform/backend/backend/api/features/chat/routes_test.py b/autogpt_platform/backend/backend/api/features/chat/routes_test.py index be3f0962fb..cd87fe611f 100644 --- a/autogpt_platform/backend/backend/api/features/chat/routes_test.py +++ b/autogpt_platform/backend/backend/api/features/chat/routes_test.py @@ -541,3 +541,41 @@ def test_create_session_rejects_nested_metadata( ) assert response.status_code == 422 + + +class TestStreamChatRequestModeValidation: + """Pydantic-level validation of the ``mode`` field on StreamChatRequest.""" + + def test_rejects_invalid_mode_value(self) -> None: + """Any string outside the Literal set must raise ValidationError.""" + from pydantic import ValidationError + + from backend.api.features.chat.routes import StreamChatRequest + + with pytest.raises(ValidationError): + StreamChatRequest(message="hi", mode="turbo") # type: ignore[arg-type] + + def test_accepts_fast_mode(self) -> None: + from backend.api.features.chat.routes import StreamChatRequest + + req = StreamChatRequest(message="hi", mode="fast") + assert req.mode == "fast" + + def test_accepts_extended_thinking_mode(self) -> None: + from backend.api.features.chat.routes import StreamChatRequest + + req = StreamChatRequest(message="hi", mode="extended_thinking") + assert req.mode == "extended_thinking" + + def test_accepts_none_mode(self) -> None: + """``mode=None`` is valid (server decides via feature flags).""" + from backend.api.features.chat.routes import StreamChatRequest + + req = StreamChatRequest(message="hi", mode=None) + assert req.mode is None + + def test_mode_defaults_to_none_when_omitted(self) -> None: + from backend.api.features.chat.routes import StreamChatRequest + + req = StreamChatRequest(message="hi") + assert req.mode is None diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes.py b/autogpt_platform/backend/backend/api/features/workspace/routes.py index 8ca339edbd..39bcc6c7c4 100644 --- a/autogpt_platform/backend/backend/api/features/workspace/routes.py +++ b/autogpt_platform/backend/backend/api/features/workspace/routes.py @@ -12,7 +12,7 @@ import fastapi from autogpt_libs.auth.dependencies import get_user_id, requires_user from fastapi import Query, UploadFile from fastapi.responses import Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from backend.data.workspace import ( WorkspaceFile, @@ -131,9 +131,26 @@ class StorageUsageResponse(BaseModel): file_count: int +class WorkspaceFileItem(BaseModel): + id: str + name: str + path: str + mime_type: str + size_bytes: int + metadata: dict = Field(default_factory=dict) + created_at: str + + +class ListFilesResponse(BaseModel): + files: list[WorkspaceFileItem] + offset: int = 0 + has_more: bool = False + + @router.get( "/files/{file_id}/download", summary="Download file by ID", + operation_id="getWorkspaceDownloadFileById", ) async def download_file( user_id: Annotated[str, fastapi.Security(get_user_id)], @@ -158,6 +175,7 @@ async def download_file( @router.delete( "/files/{file_id}", summary="Delete a workspace file", + operation_id="deleteWorkspaceFile", ) async def delete_workspace_file( user_id: Annotated[str, fastapi.Security(get_user_id)], @@ -183,6 +201,7 @@ async def delete_workspace_file( @router.post( "/files/upload", summary="Upload file to workspace", + operation_id="uploadWorkspaceFile", ) async def upload_file( user_id: Annotated[str, fastapi.Security(get_user_id)], @@ -196,6 +215,9 @@ async def upload_file( Files are stored in session-scoped paths when session_id is provided, so the agent's session-scoped tools can discover them automatically. """ + # Empty-string session_id drops session scoping; normalize to None. + session_id = session_id or None + config = Config() # Sanitize filename — strip any directory components @@ -250,16 +272,27 @@ async def upload_file( manager = WorkspaceManager(user_id, workspace.id, session_id) try: workspace_file = await manager.write_file( - content, filename, overwrite=overwrite + content, filename, overwrite=overwrite, metadata={"origin": "user-upload"} ) except ValueError as e: - raise fastapi.HTTPException(status_code=409, detail=str(e)) from e + # write_file raises ValueError for both path-conflict and size-limit + # cases; map each to its correct HTTP status. + message = str(e) + if message.startswith("File too large"): + raise fastapi.HTTPException(status_code=413, detail=message) from e + raise fastapi.HTTPException(status_code=409, detail=message) from e # Post-write storage check — eliminates TOCTOU race on the quota. # If a concurrent upload pushed us over the limit, undo this write. new_total = await get_workspace_total_size(workspace.id) if storage_limit_bytes and new_total > storage_limit_bytes: - await soft_delete_workspace_file(workspace_file.id, workspace.id) + try: + await soft_delete_workspace_file(workspace_file.id, workspace.id) + except Exception as e: + logger.warning( + f"Failed to soft-delete over-quota file {workspace_file.id} " + f"in workspace {workspace.id}: {e}" + ) raise fastapi.HTTPException( status_code=413, detail={ @@ -281,6 +314,7 @@ async def upload_file( @router.get( "/storage/usage", summary="Get workspace storage usage", + operation_id="getWorkspaceStorageUsage", ) async def get_storage_usage( user_id: Annotated[str, fastapi.Security(get_user_id)], @@ -301,3 +335,57 @@ async def get_storage_usage( used_percent=round((used_bytes / limit_bytes) * 100, 1) if limit_bytes else 0, file_count=file_count, ) + + +@router.get( + "/files", + summary="List workspace files", + operation_id="listWorkspaceFiles", +) +async def list_workspace_files( + user_id: Annotated[str, fastapi.Security(get_user_id)], + session_id: str | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=1000), + offset: int = Query(default=0, ge=0), +) -> ListFilesResponse: + """ + List files in the user's workspace. + + When session_id is provided, only files for that session are returned. + Otherwise, all files across sessions are listed. Results are paginated + via `limit`/`offset`; `has_more` indicates whether additional pages exist. + """ + workspace = await get_or_create_workspace(user_id) + + # Treat empty-string session_id the same as omitted — an empty value + # would otherwise silently list files across every session instead of + # scoping to one. + session_id = session_id or None + + manager = WorkspaceManager(user_id, workspace.id, session_id) + include_all = session_id is None + # Fetch one extra to compute has_more without a separate count query. + files = await manager.list_files( + limit=limit + 1, + offset=offset, + include_all_sessions=include_all, + ) + has_more = len(files) > limit + page = files[:limit] + + return ListFilesResponse( + files=[ + WorkspaceFileItem( + id=f.id, + name=f.name, + path=f.path, + mime_type=f.mime_type, + size_bytes=f.size_bytes, + metadata=f.metadata or {}, + created_at=f.created_at.isoformat(), + ) + for f in page + ], + offset=offset, + has_more=has_more, + ) diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes_test.py b/autogpt_platform/backend/backend/api/features/workspace/routes_test.py index 76da67aaa1..42726ba051 100644 --- a/autogpt_platform/backend/backend/api/features/workspace/routes_test.py +++ b/autogpt_platform/backend/backend/api/features/workspace/routes_test.py @@ -1,48 +1,28 @@ -"""Tests for workspace file upload and download routes.""" - import io from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch import fastapi import fastapi.testclient import pytest -import pytest_mock -from backend.api.features.workspace import routes as workspace_routes -from backend.data.workspace import WorkspaceFile +from backend.api.features.workspace.routes import router +from backend.data.workspace import Workspace, WorkspaceFile app = fastapi.FastAPI() -app.include_router(workspace_routes.router) +app.include_router(router) @app.exception_handler(ValueError) async def _value_error_handler( request: fastapi.Request, exc: ValueError ) -> fastapi.responses.JSONResponse: - """Mirror the production ValueError → 400 mapping from rest_api.py.""" + """Mirror the production ValueError → 400 mapping from the REST app.""" return fastapi.responses.JSONResponse(status_code=400, content={"detail": str(exc)}) client = fastapi.testclient.TestClient(app) -TEST_USER_ID = "3e53486c-cf57-477e-ba2a-cb02dc828e1a" - -MOCK_WORKSPACE = type("W", (), {"id": "ws-1"})() - -_NOW = datetime(2023, 1, 1, tzinfo=timezone.utc) - -MOCK_FILE = WorkspaceFile( - id="file-aaa-bbb", - workspace_id="ws-1", - created_at=_NOW, - updated_at=_NOW, - name="hello.txt", - path="/session/hello.txt", - mime_type="text/plain", - size_bytes=13, - storage_path="local://hello.txt", -) - @pytest.fixture(autouse=True) def setup_app_auth(mock_jwt_user): @@ -53,25 +33,201 @@ def setup_app_auth(mock_jwt_user): app.dependency_overrides.clear() +def _make_workspace(user_id: str = "test-user-id") -> Workspace: + return Workspace( + id="ws-001", + user_id=user_id, + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + + +def _make_file(**overrides) -> WorkspaceFile: + defaults = { + "id": "file-001", + "workspace_id": "ws-001", + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "name": "test.txt", + "path": "/test.txt", + "storage_path": "local://test.txt", + "mime_type": "text/plain", + "size_bytes": 100, + "checksum": None, + "is_deleted": False, + "deleted_at": None, + "metadata": {}, + } + defaults.update(overrides) + return WorkspaceFile(**defaults) + + +def _make_file_mock(**overrides) -> MagicMock: + """Create a mock WorkspaceFile to simulate DB records with null fields.""" + defaults = { + "id": "file-001", + "name": "test.txt", + "path": "/test.txt", + "mime_type": "text/plain", + "size_bytes": 100, + "metadata": {}, + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + } + defaults.update(overrides) + mock = MagicMock(spec=WorkspaceFile) + for k, v in defaults.items(): + setattr(mock, k, v) + return mock + + +# -- list_workspace_files tests -- + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_returns_all_when_no_session(mock_manager_cls, mock_get_workspace): + mock_get_workspace.return_value = _make_workspace() + files = [ + _make_file(id="f1", name="a.txt", metadata={"origin": "user-upload"}), + _make_file(id="f2", name="b.csv", metadata={"origin": "agent-created"}), + ] + mock_instance = AsyncMock() + mock_instance.list_files.return_value = files + mock_manager_cls.return_value = mock_instance + + response = client.get("/files") + assert response.status_code == 200 + + data = response.json() + assert len(data["files"]) == 2 + assert data["has_more"] is False + assert data["offset"] == 0 + assert data["files"][0]["id"] == "f1" + assert data["files"][0]["metadata"] == {"origin": "user-upload"} + assert data["files"][1]["id"] == "f2" + mock_instance.list_files.assert_called_once_with( + limit=201, offset=0, include_all_sessions=True + ) + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_scopes_to_session_when_provided( + mock_manager_cls, mock_get_workspace, test_user_id +): + mock_get_workspace.return_value = _make_workspace(user_id=test_user_id) + mock_instance = AsyncMock() + mock_instance.list_files.return_value = [] + mock_manager_cls.return_value = mock_instance + + response = client.get("/files?session_id=sess-123") + assert response.status_code == 200 + + data = response.json() + assert data["files"] == [] + assert data["has_more"] is False + mock_manager_cls.assert_called_once_with(test_user_id, "ws-001", "sess-123") + mock_instance.list_files.assert_called_once_with( + limit=201, offset=0, include_all_sessions=False + ) + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_null_metadata_coerced_to_empty_dict( + mock_manager_cls, mock_get_workspace +): + """Route uses `f.metadata or {}` for pre-existing files with null metadata.""" + mock_get_workspace.return_value = _make_workspace() + mock_instance = AsyncMock() + mock_instance.list_files.return_value = [_make_file_mock(metadata=None)] + mock_manager_cls.return_value = mock_instance + + response = client.get("/files") + assert response.status_code == 200 + assert response.json()["files"][0]["metadata"] == {} + + +# -- upload_file metadata tests -- + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.get_workspace_total_size") +@patch("backend.api.features.workspace.routes.scan_content_safe") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_upload_passes_user_upload_origin_metadata( + mock_manager_cls, mock_scan, mock_total_size, mock_get_workspace +): + mock_get_workspace.return_value = _make_workspace() + mock_total_size.return_value = 100 + written = _make_file(id="new-file", name="doc.pdf") + mock_instance = AsyncMock() + mock_instance.write_file.return_value = written + mock_manager_cls.return_value = mock_instance + + response = client.post( + "/files/upload", + files={"file": ("doc.pdf", b"fake-pdf-content", "application/pdf")}, + ) + assert response.status_code == 200 + + mock_instance.write_file.assert_called_once() + call_kwargs = mock_instance.write_file.call_args + assert call_kwargs.kwargs.get("metadata") == {"origin": "user-upload"} + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.get_workspace_total_size") +@patch("backend.api.features.workspace.routes.scan_content_safe") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_upload_returns_409_on_file_conflict( + mock_manager_cls, mock_scan, mock_total_size, mock_get_workspace +): + mock_get_workspace.return_value = _make_workspace() + mock_total_size.return_value = 100 + mock_instance = AsyncMock() + mock_instance.write_file.side_effect = ValueError("File already exists at path") + mock_manager_cls.return_value = mock_instance + + response = client.post( + "/files/upload", + files={"file": ("dup.txt", b"content", "text/plain")}, + ) + assert response.status_code == 409 + assert "already exists" in response.json()["detail"] + + +# -- Restored upload/download/delete security + invariant tests -- + + def _upload( filename: str = "hello.txt", content: bytes = b"Hello, world!", content_type: str = "text/plain", ): - """Helper to POST a file upload.""" return client.post( "/files/upload?session_id=sess-1", files={"file": (filename, io.BytesIO(content), content_type)}, ) -# ---- Happy path ---- +_MOCK_FILE = WorkspaceFile( + id="file-aaa-bbb", + workspace_id="ws-001", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + name="hello.txt", + path="/sessions/sess-1/hello.txt", + mime_type="text/plain", + size_bytes=13, + storage_path="local://hello.txt", +) -def test_upload_happy_path(mocker: pytest_mock.MockFixture): +def test_upload_happy_path(mocker): mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -82,7 +238,7 @@ def test_upload_happy_path(mocker: pytest_mock.MockFixture): return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -96,10 +252,7 @@ def test_upload_happy_path(mocker: pytest_mock.MockFixture): assert data["size_bytes"] == 13 -# ---- Per-file size limit ---- - - -def test_upload_exceeds_max_file_size(mocker: pytest_mock.MockFixture): +def test_upload_exceeds_max_file_size(mocker): """Files larger than max_file_size_mb should be rejected with 413.""" cfg = mocker.patch("backend.api.features.workspace.routes.Config") cfg.return_value.max_file_size_mb = 0 # 0 MB → any content is too big @@ -109,15 +262,11 @@ def test_upload_exceeds_max_file_size(mocker: pytest_mock.MockFixture): assert response.status_code == 413 -# ---- Storage quota exceeded ---- - - -def test_upload_storage_quota_exceeded(mocker: pytest_mock.MockFixture): +def test_upload_storage_quota_exceeded(mocker): mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) - # Current usage already at limit mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", return_value=500 * 1024 * 1024, @@ -128,27 +277,22 @@ def test_upload_storage_quota_exceeded(mocker: pytest_mock.MockFixture): assert "Storage limit exceeded" in response.text -# ---- Post-write quota race (B2) ---- - - -def test_upload_post_write_quota_race(mocker: pytest_mock.MockFixture): - """If a concurrent upload tips the total over the limit after write, - the file should be soft-deleted and 413 returned.""" +def test_upload_post_write_quota_race(mocker): + """Concurrent upload tipping over limit after write should soft-delete + 413.""" mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) - # Pre-write check passes (under limit), but post-write check fails mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", - side_effect=[0, 600 * 1024 * 1024], # first call OK, second over limit + side_effect=[0, 600 * 1024 * 1024], ) mocker.patch( "backend.api.features.workspace.routes.scan_content_safe", return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -160,17 +304,14 @@ def test_upload_post_write_quota_race(mocker: pytest_mock.MockFixture): response = _upload() assert response.status_code == 413 - mock_delete.assert_called_once_with("file-aaa-bbb", "ws-1") + mock_delete.assert_called_once_with("file-aaa-bbb", "ws-001") -# ---- Any extension accepted (no allowlist) ---- - - -def test_upload_any_extension(mocker: pytest_mock.MockFixture): +def test_upload_any_extension(mocker): """Any file extension should be accepted — ClamAV is the security layer.""" mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -181,7 +322,7 @@ def test_upload_any_extension(mocker: pytest_mock.MockFixture): return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -191,16 +332,13 @@ def test_upload_any_extension(mocker: pytest_mock.MockFixture): assert response.status_code == 200 -# ---- Virus scan rejection ---- - - -def test_upload_blocked_by_virus_scan(mocker: pytest_mock.MockFixture): +def test_upload_blocked_by_virus_scan(mocker): """Files flagged by ClamAV should be rejected and never written to storage.""" from backend.api.features.store.exceptions import VirusDetectedError mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -211,7 +349,7 @@ def test_upload_blocked_by_virus_scan(mocker: pytest_mock.MockFixture): side_effect=VirusDetectedError("Eicar-Test-Signature"), ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -219,18 +357,14 @@ def test_upload_blocked_by_virus_scan(mocker: pytest_mock.MockFixture): response = _upload(filename="evil.exe", content=b"X5O!P%@AP...") assert response.status_code == 400 - assert "Virus detected" in response.text mock_manager.write_file.assert_not_called() -# ---- No file extension ---- - - -def test_upload_file_without_extension(mocker: pytest_mock.MockFixture): +def test_upload_file_without_extension(mocker): """Files without an extension should be accepted and stored as-is.""" mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -241,7 +375,7 @@ def test_upload_file_without_extension(mocker: pytest_mock.MockFixture): return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -257,14 +391,11 @@ def test_upload_file_without_extension(mocker: pytest_mock.MockFixture): assert mock_manager.write_file.call_args[0][1] == "Makefile" -# ---- Filename sanitization (SF5) ---- - - -def test_upload_strips_path_components(mocker: pytest_mock.MockFixture): +def test_upload_strips_path_components(mocker): """Path-traversal filenames should be reduced to their basename.""" mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -275,28 +406,23 @@ def test_upload_strips_path_components(mocker: pytest_mock.MockFixture): return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, ) - # Filename with traversal _upload(filename="../../etc/passwd.txt") - # write_file should have been called with just the basename mock_manager.write_file.assert_called_once() call_args = mock_manager.write_file.call_args assert call_args[0][1] == "passwd.txt" -# ---- Download ---- - - -def test_download_file_not_found(mocker: pytest_mock.MockFixture): +def test_download_file_not_found(mocker): mocker.patch( "backend.api.features.workspace.routes.get_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_file", @@ -307,14 +433,11 @@ def test_download_file_not_found(mocker: pytest_mock.MockFixture): assert response.status_code == 404 -# ---- Delete ---- - - -def test_delete_file_success(mocker: pytest_mock.MockFixture): +def test_delete_file_success(mocker): """Deleting an existing file should return {"deleted": true}.""" mocker.patch( "backend.api.features.workspace.routes.get_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mock_manager = mocker.MagicMock() mock_manager.delete_file = mocker.AsyncMock(return_value=True) @@ -329,11 +452,11 @@ def test_delete_file_success(mocker: pytest_mock.MockFixture): mock_manager.delete_file.assert_called_once_with("file-aaa-bbb") -def test_delete_file_not_found(mocker: pytest_mock.MockFixture): +def test_delete_file_not_found(mocker): """Deleting a non-existent file should return 404.""" mocker.patch( "backend.api.features.workspace.routes.get_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mock_manager = mocker.MagicMock() mock_manager.delete_file = mocker.AsyncMock(return_value=False) @@ -347,7 +470,7 @@ def test_delete_file_not_found(mocker: pytest_mock.MockFixture): assert "File not found" in response.text -def test_delete_file_no_workspace(mocker: pytest_mock.MockFixture): +def test_delete_file_no_workspace(mocker): """Deleting when user has no workspace should return 404.""" mocker.patch( "backend.api.features.workspace.routes.get_workspace", @@ -357,3 +480,123 @@ def test_delete_file_no_workspace(mocker: pytest_mock.MockFixture): response = client.delete("/files/file-aaa-bbb") assert response.status_code == 404 assert "Workspace not found" in response.text + + +def test_upload_write_file_too_large_returns_413(mocker): + """write_file raises ValueError("File too large: …") → must map to 413.""" + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=_make_workspace(), + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock( + side_effect=ValueError("File too large: 900 bytes exceeds 1MB limit") + ) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + response = _upload() + assert response.status_code == 413 + assert "File too large" in response.text + + +def test_upload_write_file_conflict_returns_409(mocker): + """Non-'File too large' ValueErrors from write_file stay as 409.""" + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=_make_workspace(), + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock( + side_effect=ValueError("File already exists at path: /sessions/x/a.txt") + ) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + response = _upload() + assert response.status_code == 409 + assert "already exists" in response.text + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_has_more_true_when_limit_exceeded( + mock_manager_cls, mock_get_workspace +): + """The limit+1 fetch trick must flip has_more=True and trim the page.""" + mock_get_workspace.return_value = _make_workspace() + # Backend was asked for limit+1=3, and returned exactly 3 items. + files = [ + _make_file(id="f1", name="a.txt"), + _make_file(id="f2", name="b.txt"), + _make_file(id="f3", name="c.txt"), + ] + mock_instance = AsyncMock() + mock_instance.list_files.return_value = files + mock_manager_cls.return_value = mock_instance + + response = client.get("/files?limit=2") + assert response.status_code == 200 + data = response.json() + assert data["has_more"] is True + assert len(data["files"]) == 2 + assert data["files"][0]["id"] == "f1" + assert data["files"][1]["id"] == "f2" + mock_instance.list_files.assert_called_once_with( + limit=3, offset=0, include_all_sessions=True + ) + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_has_more_false_when_exactly_page_size( + mock_manager_cls, mock_get_workspace +): + """Exactly `limit` rows means we're on the last page — has_more=False.""" + mock_get_workspace.return_value = _make_workspace() + files = [_make_file(id="f1", name="a.txt"), _make_file(id="f2", name="b.txt")] + mock_instance = AsyncMock() + mock_instance.list_files.return_value = files + mock_manager_cls.return_value = mock_instance + + response = client.get("/files?limit=2") + assert response.status_code == 200 + data = response.json() + assert data["has_more"] is False + assert len(data["files"]) == 2 + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_offset_is_echoed_back(mock_manager_cls, mock_get_workspace): + mock_get_workspace.return_value = _make_workspace() + mock_instance = AsyncMock() + mock_instance.list_files.return_value = [] + mock_manager_cls.return_value = mock_instance + + response = client.get("/files?offset=50&limit=10") + assert response.status_code == 200 + assert response.json()["offset"] == 50 + mock_instance.list_files.assert_called_once_with( + limit=11, offset=50, include_all_sessions=True + ) diff --git a/autogpt_platform/backend/backend/blocks/llm.py b/autogpt_platform/backend/backend/blocks/llm.py index e3e34c9968..66f87b7f47 100644 --- a/autogpt_platform/backend/backend/blocks/llm.py +++ b/autogpt_platform/backend/backend/blocks/llm.py @@ -205,6 +205,19 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta): KIMI_K2 = "moonshotai/kimi-k2" QWEN3_235B_A22B_THINKING = "qwen/qwen3-235b-a22b-thinking-2507" QWEN3_CODER = "qwen/qwen3-coder" + # Z.ai (Zhipu) models + ZAI_GLM_4_32B = "z-ai/glm-4-32b" + ZAI_GLM_4_5 = "z-ai/glm-4.5" + ZAI_GLM_4_5_AIR = "z-ai/glm-4.5-air" + ZAI_GLM_4_5_AIR_FREE = "z-ai/glm-4.5-air:free" + ZAI_GLM_4_5V = "z-ai/glm-4.5v" + ZAI_GLM_4_6 = "z-ai/glm-4.6" + ZAI_GLM_4_6V = "z-ai/glm-4.6v" + ZAI_GLM_4_7 = "z-ai/glm-4.7" + ZAI_GLM_4_7_FLASH = "z-ai/glm-4.7-flash" + ZAI_GLM_5 = "z-ai/glm-5" + ZAI_GLM_5_TURBO = "z-ai/glm-5-turbo" + ZAI_GLM_5V_TURBO = "z-ai/glm-5v-turbo" # Llama API models LLAMA_API_LLAMA_4_SCOUT = "Llama-4-Scout-17B-16E-Instruct-FP8" LLAMA_API_LLAMA4_MAVERICK = "Llama-4-Maverick-17B-128E-Instruct-FP8" @@ -630,6 +643,43 @@ MODEL_METADATA = { LlmModel.QWEN3_CODER: ModelMetadata( "open_router", 262144, 262144, "Qwen 3 Coder", "OpenRouter", "Qwen", 3 ), + # https://openrouter.ai/models?q=z-ai + LlmModel.ZAI_GLM_4_32B: ModelMetadata( + "open_router", 128000, 128000, "GLM 4 32B", "OpenRouter", "Z.ai", 1 + ), + LlmModel.ZAI_GLM_4_5: ModelMetadata( + "open_router", 131072, 98304, "GLM 4.5", "OpenRouter", "Z.ai", 2 + ), + LlmModel.ZAI_GLM_4_5_AIR: ModelMetadata( + "open_router", 131072, 98304, "GLM 4.5 Air", "OpenRouter", "Z.ai", 1 + ), + LlmModel.ZAI_GLM_4_5_AIR_FREE: ModelMetadata( + "open_router", 131072, 96000, "GLM 4.5 Air (Free)", "OpenRouter", "Z.ai", 1 + ), + LlmModel.ZAI_GLM_4_5V: ModelMetadata( + "open_router", 65536, 16384, "GLM 4.5V", "OpenRouter", "Z.ai", 2 + ), + LlmModel.ZAI_GLM_4_6: ModelMetadata( + "open_router", 204800, 204800, "GLM 4.6", "OpenRouter", "Z.ai", 1 + ), + LlmModel.ZAI_GLM_4_6V: ModelMetadata( + "open_router", 131072, 131072, "GLM 4.6V", "OpenRouter", "Z.ai", 1 + ), + LlmModel.ZAI_GLM_4_7: ModelMetadata( + "open_router", 202752, 65535, "GLM 4.7", "OpenRouter", "Z.ai", 1 + ), + LlmModel.ZAI_GLM_4_7_FLASH: ModelMetadata( + "open_router", 202752, 202752, "GLM 4.7 Flash", "OpenRouter", "Z.ai", 1 + ), + LlmModel.ZAI_GLM_5: ModelMetadata( + "open_router", 80000, 80000, "GLM 5", "OpenRouter", "Z.ai", 2 + ), + LlmModel.ZAI_GLM_5_TURBO: ModelMetadata( + "open_router", 202752, 131072, "GLM 5 Turbo", "OpenRouter", "Z.ai", 3 + ), + LlmModel.ZAI_GLM_5V_TURBO: ModelMetadata( + "open_router", 202752, 131072, "GLM 5V Turbo", "OpenRouter", "Z.ai", 3 + ), # Llama API models LlmModel.LLAMA_API_LLAMA_4_SCOUT: ModelMetadata( "llama_api", diff --git a/autogpt_platform/backend/backend/copilot/baseline/service.py b/autogpt_platform/backend/backend/copilot/baseline/service.py index 379686b64d..abbe159b9b 100644 --- a/autogpt_platform/backend/backend/copilot/baseline/service.py +++ b/autogpt_platform/backend/backend/copilot/baseline/service.py @@ -7,22 +7,29 @@ shared tool registry as the SDK path. """ import asyncio +import base64 import logging +import os +import re +import shutil +import tempfile import uuid from collections.abc import AsyncGenerator, Sequence from dataclasses import dataclass, field from functools import partial -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import orjson from langfuse import propagate_attributes from openai.types.chat import ChatCompletionMessageParam, ChatCompletionToolParam -from backend.copilot.context import set_execution_context +from backend.copilot.config import CopilotMode +from backend.copilot.context import get_workspace_manager, set_execution_context from backend.copilot.model import ( ChatMessage, ChatSession, get_chat_session, + maybe_append_user_message, update_session_title, upsert_chat_session, ) @@ -51,6 +58,15 @@ from backend.copilot.service import ( from backend.copilot.token_tracking import persist_and_record_usage from backend.copilot.tools import execute_tool, get_available_tools from backend.copilot.tracking import track_user_message +from backend.copilot.transcript import ( + STOP_REASON_END_TURN, + STOP_REASON_TOOL_USE, + TranscriptDownload, + download_transcript, + upload_transcript, + validate_transcript, +) +from backend.copilot.transcript_builder import TranscriptBuilder from backend.util.exceptions import NotFoundError from backend.util.prompt import ( compress_context, @@ -64,6 +80,9 @@ from backend.util.tool_call_loop import ( tool_call_loop, ) +if TYPE_CHECKING: + from backend.copilot.permissions import CopilotPermissions + logger = logging.getLogger(__name__) # Set to hold background tasks to prevent garbage collection @@ -72,6 +91,233 @@ _background_tasks: set[asyncio.Task[Any]] = set() # Maximum number of tool-call rounds before forcing a text response. _MAX_TOOL_ROUNDS = 30 +# Max seconds to wait for transcript upload in the finally block before +# letting it continue as a background task (tracked in _background_tasks). +_TRANSCRIPT_UPLOAD_TIMEOUT_S = 5 + +# MIME types that can be embedded as vision content blocks (OpenAI format). +_VISION_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"}) + +# Max size for embedding images directly in the user message (20 MiB raw). +_MAX_INLINE_IMAGE_BYTES = 20 * 1024 * 1024 + +# Matches characters unsafe for filenames. +_UNSAFE_FILENAME = re.compile(r"[^\w.\-]") + + +async def _prepare_baseline_attachments( + file_ids: list[str], + user_id: str, + session_id: str, + working_dir: str, +) -> tuple[str, list[dict[str, Any]]]: + """Download workspace files and prepare them for the baseline LLM. + + Images become OpenAI-format vision content blocks. Non-image files are + saved to *working_dir* so tool handlers can access them. + + Returns ``(hint_text, image_blocks)``. + """ + if not file_ids or not user_id: + return "", [] + + try: + manager = await get_workspace_manager(user_id, session_id) + except Exception: + logger.warning( + "Failed to create workspace manager for file attachments", + exc_info=True, + ) + return "", [] + + image_blocks: list[dict[str, Any]] = [] + file_descriptions: list[str] = [] + + for fid in file_ids: + try: + file_info = await manager.get_file_info(fid) + if file_info is None: + continue + content = await manager.read_file_by_id(fid) + mime = (file_info.mime_type or "").split(";")[0].strip().lower() + + if mime in _VISION_MIME_TYPES and len(content) <= _MAX_INLINE_IMAGE_BYTES: + b64 = base64.b64encode(content).decode("ascii") + image_blocks.append( + { + "type": "image", + "source": {"type": "base64", "media_type": mime, "data": b64}, + } + ) + file_descriptions.append( + f"- {file_info.name} ({mime}, " + f"{file_info.size_bytes:,} bytes) [embedded as image]" + ) + else: + safe = _UNSAFE_FILENAME.sub("_", file_info.name) or "file" + candidate = os.path.join(working_dir, safe) + if os.path.exists(candidate): + stem, ext = os.path.splitext(safe) + idx = 1 + while os.path.exists(candidate): + candidate = os.path.join(working_dir, f"{stem}_{idx}{ext}") + idx += 1 + with open(candidate, "wb") as f: + f.write(content) + file_descriptions.append( + f"- {file_info.name} ({mime}, " + f"{file_info.size_bytes:,} bytes) saved to " + f"{os.path.basename(candidate)}" + ) + except Exception: + logger.warning("Failed to prepare file %s", fid[:12], exc_info=True) + + if not file_descriptions: + return "", [] + + noun = "file" if len(file_descriptions) == 1 else "files" + has_non_images = len(file_descriptions) > len(image_blocks) + read_hint = ( + " Use the read_workspace_file tool to view non-image files." + if has_non_images + else "" + ) + hint = ( + f"\n[The user attached {len(file_descriptions)} {noun}.{read_hint}\n" + + "\n".join(file_descriptions) + + "]" + ) + return hint, image_blocks + + +def _filter_tools_by_permissions( + tools: list[ChatCompletionToolParam], + permissions: "CopilotPermissions", +) -> list[ChatCompletionToolParam]: + """Filter OpenAI-format tools based on CopilotPermissions. + + Uses short tool names (the ``function.name`` field) to compute the + effective allowed set, then keeps only matching tools. + """ + from backend.copilot.permissions import all_known_tool_names + + if permissions.is_empty(): + return tools + + all_tools = all_known_tool_names() + effective = permissions.effective_allowed_tools(all_tools) + + return [ + t + for t in tools + if t.get("function", {}).get("name") in effective # type: ignore[union-attr] + ] + + +def _resolve_baseline_model(mode: CopilotMode | None) -> str: + """Pick the model for the baseline path based on the per-request mode. + + Only ``mode='fast'`` downgrades to the cheaper/faster model. Any other + value (including ``None`` and ``'extended_thinking'``) preserves the + default model so that users who never select a mode don't get + silently moved to the cheaper tier. + """ + if mode == "fast": + return config.fast_model + return config.model + + +# Tag pairs to strip from baseline streaming output. Different models use +# different tag names for their internal reasoning (Claude uses , +# Gemini uses , etc.). +_REASONING_TAG_PAIRS: list[tuple[str, str]] = [ + ("", ""), + ("", ""), +] + +# Longest opener — used to size the partial-tag buffer. +_MAX_OPEN_TAG_LEN = max(len(o) for o, _ in _REASONING_TAG_PAIRS) + + +class _ThinkingStripper: + """Strip reasoning blocks from a stream of text deltas. + + Handles multiple tag patterns (````, ````, + etc.) so the same stripper works across Claude, Gemini, and other models. + + Buffers just enough characters to detect a tag that may be split + across chunks; emits text immediately when no tag is in-flight. + Robust to single chunks that open and close a block, multiple + blocks per stream, and tags that straddle chunk boundaries. + """ + + def __init__(self) -> None: + self._buffer: str = "" + self._in_thinking: bool = False + self._close_tag: str = "" # closing tag for the currently open block + + def _find_open_tag(self) -> tuple[int, str, str]: + """Find the earliest opening tag in the buffer. + + Returns (position, open_tag, close_tag) or (-1, "", "") if none. + """ + best_pos = -1 + best_open = "" + best_close = "" + for open_tag, close_tag in _REASONING_TAG_PAIRS: + pos = self._buffer.find(open_tag) + if pos != -1 and (best_pos == -1 or pos < best_pos): + best_pos = pos + best_open = open_tag + best_close = close_tag + return best_pos, best_open, best_close + + def process(self, chunk: str) -> str: + """Feed a chunk and return the text that is safe to emit now.""" + self._buffer += chunk + out: list[str] = [] + while self._buffer: + if self._in_thinking: + end = self._buffer.find(self._close_tag) + if end == -1: + keep = len(self._close_tag) - 1 + self._buffer = self._buffer[-keep:] if keep else "" + return "".join(out) + self._buffer = self._buffer[end + len(self._close_tag) :] + self._in_thinking = False + self._close_tag = "" + else: + start, open_tag, close_tag = self._find_open_tag() + if start == -1: + # No opening tag; emit everything except a tail that + # could start a partial opener on the next chunk. + safe_end = len(self._buffer) + for keep in range( + min(_MAX_OPEN_TAG_LEN - 1, len(self._buffer)), 0, -1 + ): + tail = self._buffer[-keep:] + if any(o[:keep] == tail for o, _ in _REASONING_TAG_PAIRS): + safe_end = len(self._buffer) - keep + break + out.append(self._buffer[:safe_end]) + self._buffer = self._buffer[safe_end:] + return "".join(out) + out.append(self._buffer[:start]) + self._buffer = self._buffer[start + len(open_tag) :] + self._in_thinking = True + self._close_tag = close_tag + return "".join(out) + + def flush(self) -> str: + """Return any remaining emittable text when the stream ends.""" + if self._in_thinking: + # Unclosed thinking block — discard the buffered reasoning. + self._buffer = "" + return "" + out = self._buffer + self._buffer = "" + return out + @dataclass class _BaselineStreamState: @@ -81,12 +327,15 @@ class _BaselineStreamState: can be module-level functions instead of deeply nested closures. """ + model: str = "" pending_events: list[StreamBaseResponse] = field(default_factory=list) assistant_text: str = "" text_block_id: str = field(default_factory=lambda: str(uuid.uuid4())) text_started: bool = False turn_prompt_tokens: int = 0 turn_completion_tokens: int = 0 + thinking_stripper: _ThinkingStripper = field(default_factory=_ThinkingStripper) + session_messages: list[ChatMessage] = field(default_factory=list) async def _baseline_llm_caller( @@ -100,6 +349,9 @@ async def _baseline_llm_caller( Extracted from ``stream_chat_completion_baseline`` for readability. """ state.pending_events.append(StreamStartStep()) + # Fresh thinking-strip state per round so a malformed unclosed + # block in one LLM call cannot silently drop content in the next. + state.thinking_stripper = _ThinkingStripper() round_text = "" try: @@ -108,7 +360,7 @@ async def _baseline_llm_caller( if tools: typed_tools = cast(list[ChatCompletionToolParam], tools) response = await client.chat.completions.create( - model=config.model, + model=state.model, messages=typed_messages, tools=typed_tools, stream=True, @@ -116,7 +368,7 @@ async def _baseline_llm_caller( ) else: response = await client.chat.completions.create( - model=config.model, + model=state.model, messages=typed_messages, stream=True, stream_options={"include_usage": True}, @@ -133,13 +385,17 @@ async def _baseline_llm_caller( continue if delta.content: - if not state.text_started: - state.pending_events.append(StreamTextStart(id=state.text_block_id)) - state.text_started = True - round_text += delta.content - state.pending_events.append( - StreamTextDelta(id=state.text_block_id, delta=delta.content) - ) + emit = state.thinking_stripper.process(delta.content) + if emit: + if not state.text_started: + state.pending_events.append( + StreamTextStart(id=state.text_block_id) + ) + state.text_started = True + round_text += emit + state.pending_events.append( + StreamTextDelta(id=state.text_block_id, delta=emit) + ) if delta.tool_calls: for tc in delta.tool_calls: @@ -158,6 +414,16 @@ async def _baseline_llm_caller( if tc.function and tc.function.arguments: entry["arguments"] += tc.function.arguments + # Flush any buffered text held back by the thinking stripper. + tail = state.thinking_stripper.flush() + if tail: + if not state.text_started: + state.pending_events.append(StreamTextStart(id=state.text_block_id)) + state.text_started = True + round_text += tail + state.pending_events.append( + StreamTextDelta(id=state.text_block_id, delta=tail) + ) # Close text block if state.text_started: state.pending_events.append(StreamTextEnd(id=state.text_block_id)) @@ -278,17 +544,17 @@ async def _baseline_tool_executor( ) -def _baseline_conversation_updater( +def _mutate_openai_messages( messages: list[dict[str, Any]], response: LLMLoopResponse, - tool_results: list[ToolCallResult] | None = None, + tool_results: list[ToolCallResult] | None, ) -> None: - """Update OpenAI message list with assistant response + tool results. + """Append assistant / tool-result entries to the OpenAI message list. - Extracted from ``stream_chat_completion_baseline`` for readability. + This is the side-effect boundary for the next LLM call — no transcript + mutation happens here. """ if tool_results: - # Build assistant message with tool_calls assistant_msg: dict[str, Any] = {"role": "assistant"} if response.response_text: assistant_msg["content"] = response.response_text @@ -309,9 +575,115 @@ def _baseline_conversation_updater( "content": tr.content, } ) - else: + elif response.response_text: + messages.append({"role": "assistant", "content": response.response_text}) + + +def _record_turn_to_transcript( + response: LLMLoopResponse, + tool_results: list[ToolCallResult] | None, + *, + transcript_builder: TranscriptBuilder, + model: str, +) -> None: + """Append assistant + tool-result entries to the transcript builder. + + Kept separate from :func:`_mutate_openai_messages` so the two + concerns (next-LLM-call payload vs. durable conversation log) can + evolve independently. + """ + if tool_results: + content_blocks: list[dict[str, Any]] = [] if response.response_text: - messages.append({"role": "assistant", "content": response.response_text}) + content_blocks.append({"type": "text", "text": response.response_text}) + for tc in response.tool_calls: + try: + args = orjson.loads(tc.arguments) if tc.arguments else {} + except (ValueError, TypeError, orjson.JSONDecodeError) as parse_err: + logger.debug( + "[Baseline] Failed to parse tool_call arguments " + "(tool=%s, id=%s): %s", + tc.name, + tc.id, + parse_err, + ) + args = {} + content_blocks.append( + { + "type": "tool_use", + "id": tc.id, + "name": tc.name, + "input": args, + } + ) + if content_blocks: + transcript_builder.append_assistant( + content_blocks=content_blocks, + model=model, + stop_reason=STOP_REASON_TOOL_USE, + ) + for tr in tool_results: + # Record tool result to transcript AFTER the assistant tool_use + # block to maintain correct Anthropic API ordering: + # assistant(tool_use) → user(tool_result) + transcript_builder.append_tool_result( + tool_use_id=tr.tool_call_id, + content=tr.content, + ) + elif response.response_text: + transcript_builder.append_assistant( + content_blocks=[{"type": "text", "text": response.response_text}], + model=model, + stop_reason=STOP_REASON_END_TURN, + ) + + +def _baseline_conversation_updater( + messages: list[dict[str, Any]], + response: LLMLoopResponse, + tool_results: list[ToolCallResult] | None = None, + *, + transcript_builder: TranscriptBuilder, + model: str = "", + state: _BaselineStreamState | None = None, +) -> None: + """Update OpenAI message list with assistant response + tool results. + + Also records structured ChatMessage entries in ``state.session_messages`` + so the full tool-call history is persisted to the session (not just the + concatenated assistant text). + """ + _mutate_openai_messages(messages, response, tool_results) + _record_turn_to_transcript( + response, + tool_results, + transcript_builder=transcript_builder, + model=model, + ) + # Record structured messages for session persistence so tool calls + # and tool results survive across turns and mode switches. + if state is not None and tool_results: + assistant_msg = ChatMessage( + role="assistant", + content=response.response_text or "", + tool_calls=[ + { + "id": tc.id, + "type": "function", + "function": {"name": tc.name, "arguments": tc.arguments}, + } + for tc in response.tool_calls + ], + ) + state.session_messages.append(assistant_msg) + for tr in tool_results: + state.session_messages.append( + ChatMessage( + role="tool", + content=tr.content, + tool_call_id=tr.tool_call_id, + ) + ) async def _update_title_async( @@ -328,6 +700,7 @@ async def _update_title_async( async def _compress_session_messages( messages: list[ChatMessage], + model: str, ) -> list[ChatMessage]: """Compress session messages if they exceed the model's token limit. @@ -340,45 +713,189 @@ async def _compress_session_messages( msg_dict: dict[str, Any] = {"role": msg.role} if msg.content: msg_dict["content"] = msg.content + if msg.tool_calls: + msg_dict["tool_calls"] = msg.tool_calls + if msg.tool_call_id: + msg_dict["tool_call_id"] = msg.tool_call_id messages_dict.append(msg_dict) try: result = await compress_context( messages=messages_dict, - model=config.model, + model=model, client=_get_openai_client(), ) except Exception as e: logger.warning("[Baseline] Context compression with LLM failed: %s", e) result = await compress_context( messages=messages_dict, - model=config.model, + model=model, client=None, ) if result.was_compacted: logger.info( - "[Baseline] Context compacted: %d -> %d tokens " - "(%d summarized, %d dropped)", + "[Baseline] Context compacted: %d -> %d tokens (%d summarized, %d dropped)", result.original_token_count, result.token_count, result.messages_summarized, result.messages_dropped, ) return [ - ChatMessage(role=m["role"], content=m.get("content")) + ChatMessage( + role=m["role"], + content=m.get("content"), + tool_calls=m.get("tool_calls"), + tool_call_id=m.get("tool_call_id"), + ) for m in result.messages ] return messages +def is_transcript_stale(dl: TranscriptDownload | None, session_msg_count: int) -> bool: + """Return ``True`` when a download doesn't cover the current session. + + A transcript is stale when it has a known ``message_count`` and that + count doesn't reach ``session_msg_count - 1`` (i.e. the session has + already advanced beyond what the stored transcript captures). + Loading a stale transcript would silently drop intermediate turns, + so callers should treat stale as "skip load, skip upload". + + An unknown ``message_count`` (``0``) is treated as **not stale** + because older transcripts uploaded before msg_count tracking + existed must still be usable. + """ + if dl is None: + return False + if not dl.message_count: + return False + return dl.message_count < session_msg_count - 1 + + +def should_upload_transcript( + user_id: str | None, transcript_covers_prefix: bool +) -> bool: + """Return ``True`` when the caller should upload the final transcript. + + Uploads require a logged-in user (for the storage key) *and* a + transcript that covered the session prefix when loaded — otherwise + we'd be overwriting a more complete version in storage with a + partial one built from just the current turn. + """ + return bool(user_id) and transcript_covers_prefix + + +async def _load_prior_transcript( + user_id: str, + session_id: str, + session_msg_count: int, + transcript_builder: TranscriptBuilder, +) -> bool: + """Download and load the prior transcript into ``transcript_builder``. + + Returns ``True`` when the loaded transcript fully covers the session + prefix; ``False`` otherwise (stale, missing, invalid, or download + error). Callers should suppress uploads when this returns ``False`` + to avoid overwriting a more complete version in storage. + """ + try: + dl = await download_transcript(user_id, session_id, log_prefix="[Baseline]") + except Exception as e: + logger.warning("[Baseline] Transcript download failed: %s", e) + return False + + if dl is None: + logger.debug("[Baseline] No transcript available") + return False + + if not validate_transcript(dl.content): + logger.warning("[Baseline] Downloaded transcript but invalid") + return False + + if is_transcript_stale(dl, session_msg_count): + logger.warning( + "[Baseline] Transcript stale: covers %d of %d messages, skipping", + dl.message_count, + session_msg_count, + ) + return False + + transcript_builder.load_previous(dl.content, log_prefix="[Baseline]") + logger.info( + "[Baseline] Loaded transcript: %dB, msg_count=%d", + len(dl.content), + dl.message_count, + ) + return True + + +async def _upload_final_transcript( + user_id: str, + session_id: str, + transcript_builder: TranscriptBuilder, + session_msg_count: int, +) -> None: + """Serialize and upload the transcript for next-turn continuity. + + Uses the builder's own invariants to decide whether to upload, + avoiding a JSONL re-parse. A builder that ends with an assistant + entry is structurally complete; a builder that doesn't (empty, or + ends mid-turn) is skipped. + """ + try: + if transcript_builder.last_entry_type != "assistant": + logger.debug( + "[Baseline] No complete assistant turn to upload (last_entry=%s)", + transcript_builder.last_entry_type, + ) + return + content = transcript_builder.to_jsonl() + if not content: + logger.debug("[Baseline] Empty transcript content, skipping upload") + return + # Track the upload as a background task so a timeout doesn't leak an + # orphaned coroutine; shield it so cancellation of this caller doesn't + # abort the in-flight GCS write. + upload_task = asyncio.create_task( + upload_transcript( + user_id=user_id, + session_id=session_id, + content=content, + message_count=session_msg_count, + log_prefix="[Baseline]", + skip_strip=True, + ) + ) + _background_tasks.add(upload_task) + upload_task.add_done_callback(_background_tasks.discard) + # Bound the wait: a hung storage backend must not block the response + # from finishing. The task keeps running in _background_tasks on + # timeout and will be cleaned up when it resolves. + await asyncio.wait_for( + asyncio.shield(upload_task), timeout=_TRANSCRIPT_UPLOAD_TIMEOUT_S + ) + except asyncio.TimeoutError: + # Upload is still running in _background_tasks; we just stopped waiting. + logger.info( + "[Baseline] Transcript upload exceeded %ss wait — continuing as background task", + _TRANSCRIPT_UPLOAD_TIMEOUT_S, + ) + except Exception as upload_err: + logger.error("[Baseline] Transcript upload failed: %s", upload_err) + + async def stream_chat_completion_baseline( session_id: str, message: str | None = None, is_user_message: bool = True, user_id: str | None = None, session: ChatSession | None = None, + file_ids: list[str] | None = None, + permissions: "CopilotPermissions | None" = None, + context: dict[str, str] | None = None, + mode: CopilotMode | None = None, **_kwargs: Any, ) -> AsyncGenerator[StreamBaseResponse, None]: """Baseline LLM with tool calling via OpenAI-compatible API. @@ -397,25 +914,74 @@ async def stream_chat_completion_baseline( f"Session {session_id} not found. Please create a new session first." ) - # Append user message - new_role = "user" if is_user_message else "assistant" - if message and ( - len(session.messages) == 0 - or not ( - session.messages[-1].role == new_role - and session.messages[-1].content == message - ) - ): - session.messages.append(ChatMessage(role=new_role, content=message)) + if maybe_append_user_message(session, message, is_user_message): if is_user_message: track_user_message( user_id=user_id, session_id=session_id, - message_length=len(message), + message_length=len(message or ""), ) session = await upsert_chat_session(session) + # Select model based on the per-request mode. 'fast' downgrades to + # the cheaper/faster model; everything else keeps the default. + active_model = _resolve_baseline_model(mode) + + # --- E2B sandbox setup (feature parity with SDK path) --- + e2b_sandbox = None + e2b_api_key = config.active_e2b_api_key + if e2b_api_key: + try: + from backend.copilot.tools.e2b_sandbox import get_or_create_sandbox + + e2b_sandbox = await get_or_create_sandbox( + session_id, + api_key=e2b_api_key, + template=config.e2b_sandbox_template, + timeout=config.e2b_sandbox_timeout, + on_timeout=config.e2b_sandbox_on_timeout, + ) + except Exception: + logger.warning("[Baseline] E2B sandbox setup failed", exc_info=True) + + # --- Transcript support (feature parity with SDK path) --- + transcript_builder = TranscriptBuilder() + transcript_covers_prefix = True + + # Build system prompt only on the first turn to avoid mid-conversation + # changes from concurrent chats updating business understanding. + is_first_turn = len(session.messages) <= 1 + if is_first_turn: + prompt_task = _build_system_prompt(user_id, has_conversation_history=False) + else: + prompt_task = _build_system_prompt(user_id=None, has_conversation_history=True) + + # Run download + prompt build concurrently — both are independent I/O + # on the request critical path. + if user_id and len(session.messages) > 1: + transcript_covers_prefix, (base_system_prompt, _) = await asyncio.gather( + _load_prior_transcript( + user_id=user_id, + session_id=session_id, + session_msg_count=len(session.messages), + transcript_builder=transcript_builder, + ), + prompt_task, + ) + else: + base_system_prompt, _ = await prompt_task + + # Append user message to transcript. + # Always append when the message is present and is from the user, + # even on duplicate-suppressed retries (is_new_message=False). + # The loaded transcript may be stale (uploaded before the previous + # attempt stored this message), so skipping it would leave the + # transcript without the user turn, creating a malformed + # assistant-after-assistant structure when the LLM reply is added. + if message and is_user_message: + transcript_builder.append_user(content=message) + # Generate title for new sessions if is_user_message and not session.title: user_messages = [m for m in session.messages if m.role == "user"] @@ -430,36 +996,104 @@ async def stream_chat_completion_baseline( message_id = str(uuid.uuid4()) - # Build system prompt only on the first turn to avoid mid-conversation - # changes from concurrent chats updating business understanding. - is_first_turn = len(session.messages) <= 1 - if is_first_turn: - base_system_prompt, _ = await _build_system_prompt( - user_id, has_conversation_history=False - ) - else: - base_system_prompt, _ = await _build_system_prompt( - user_id=None, has_conversation_history=True - ) - # Append tool documentation and technical notes system_prompt = base_system_prompt + get_baseline_supplement() # Compress context if approaching the model's token limit - messages_for_context = await _compress_session_messages(session.messages) + messages_for_context = await _compress_session_messages( + session.messages, model=active_model + ) - # Build OpenAI message list from session history + # Build OpenAI message list from session history. + # Include tool_calls on assistant messages and tool-role results so the + # model retains full context of what tools were invoked and their outcomes. openai_messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt} ] for msg in messages_for_context: - if msg.role in ("user", "assistant") and msg.content: + if msg.role == "assistant": + entry: dict[str, Any] = {"role": "assistant"} + if msg.content: + entry["content"] = msg.content + if msg.tool_calls: + entry["tool_calls"] = msg.tool_calls + if msg.content or msg.tool_calls: + openai_messages.append(entry) + elif msg.role == "tool" and msg.tool_call_id: + openai_messages.append( + { + "role": "tool", + "tool_call_id": msg.tool_call_id, + "content": msg.content or "", + } + ) + elif msg.role == "user" and msg.content: openai_messages.append({"role": msg.role, "content": msg.content}) + # --- File attachments (feature parity with SDK path) --- + working_dir: str | None = None + attachment_hint = "" + image_blocks: list[dict[str, Any]] = [] + if file_ids and user_id: + working_dir = tempfile.mkdtemp(prefix=f"copilot-baseline-{session_id[:8]}-") + attachment_hint, image_blocks = await _prepare_baseline_attachments( + file_ids, user_id, session_id, working_dir + ) + + # --- URL context --- + context_hint = "" + if context and context.get("url"): + url = context["url"] + content_text = context.get("content", "") + if content_text: + context_hint = ( + f"\n[The user shared a URL: {url}\n" f"Content:\n{content_text[:8000]}]" + ) + else: + context_hint = f"\n[The user shared a URL: {url}]" + + # Append attachment + context hints and image blocks to the last user + # message in a single reverse scan. + extra_hint = attachment_hint + context_hint + if extra_hint or image_blocks: + for i in range(len(openai_messages) - 1, -1, -1): + if openai_messages[i].get("role") == "user": + existing = openai_messages[i].get("content", "") + if isinstance(existing, str): + text = existing + "\n" + extra_hint if extra_hint else existing + if image_blocks: + parts: list[dict[str, Any]] = [{"type": "text", "text": text}] + for img in image_blocks: + parts.append( + { + "type": "image_url", + "image_url": { + "url": ( + f"data:{img['source']['media_type']};" + f"base64,{img['source']['data']}" + ) + }, + } + ) + openai_messages[i]["content"] = parts + else: + openai_messages[i]["content"] = text + break + tools = get_available_tools() + # --- Permission filtering --- + if permissions is not None: + tools = _filter_tools_by_permissions(tools, permissions) + # Propagate execution context so tool handlers can read session-level flags. - set_execution_context(user_id, session) + set_execution_context( + user_id, + session, + sandbox=e2b_sandbox, + sdk_cwd=working_dir, + permissions=permissions, + ) yield StreamStart(messageId=message_id, sessionId=session_id) @@ -478,7 +1112,7 @@ async def stream_chat_completion_baseline( logger.warning("[Baseline] Langfuse trace context setup failed") _stream_error = False # Track whether an error occurred during streaming - state = _BaselineStreamState() + state = _BaselineStreamState(model=active_model) # Bind extracted module-level callbacks to this request's state/session # using functools.partial so they satisfy the Protocol signatures. @@ -487,6 +1121,13 @@ async def stream_chat_completion_baseline( _baseline_tool_executor, state=state, user_id=user_id, session=session ) + _bound_conversation_updater = partial( + _baseline_conversation_updater, + transcript_builder=transcript_builder, + model=active_model, + state=state, + ) + try: loop_result = None async for loop_result in tool_call_loop( @@ -494,7 +1135,7 @@ async def stream_chat_completion_baseline( tools=tools, llm_call=_bound_llm_caller, execute_tool=_bound_tool_executor, - update_conversation=_baseline_conversation_updater, + update_conversation=_bound_conversation_updater, max_iterations=_MAX_TOOL_ROUNDS, ): # Drain buffered events after each iteration (real-time streaming) @@ -563,10 +1204,10 @@ async def stream_chat_completion_baseline( and not (_stream_error and not state.assistant_text) ): state.turn_prompt_tokens = max( - estimate_token_count(openai_messages, model=config.model), 1 + estimate_token_count(openai_messages, model=active_model), 1 ) state.turn_completion_tokens = estimate_token_count_str( - state.assistant_text, model=config.model + state.assistant_text, model=active_model ) logger.info( "[Baseline] No streaming usage reported; estimated tokens: " @@ -587,16 +1228,54 @@ async def stream_chat_completion_baseline( log_prefix="[Baseline]", ) - # Persist assistant response - if state.assistant_text: - session.messages.append( - ChatMessage(role="assistant", content=state.assistant_text) + # Persist structured tool-call history (assistant + tool messages) + # collected by the conversation updater, then the final text response. + for msg in state.session_messages: + session.messages.append(msg) + # Append the final assistant text (from the last LLM call that had + # no tool calls, i.e. the natural finish). Only add it if the + # conversation updater didn't already record it as part of a + # tool-call round (which would have empty response_text). + final_text = state.assistant_text + if state.session_messages: + # Strip text already captured in tool-call round messages + recorded = "".join( + m.content or "" for m in state.session_messages if m.role == "assistant" ) + if final_text.startswith(recorded): + final_text = final_text[len(recorded) :] + if final_text.strip(): + session.messages.append(ChatMessage(role="assistant", content=final_text)) try: await upsert_chat_session(session) except Exception as persist_err: logger.error("[Baseline] Failed to persist session: %s", persist_err) + # --- Upload transcript for next-turn continuity --- + # Backfill partial assistant text that wasn't recorded by the + # conversation updater (e.g. when the stream aborted mid-round). + # Without this, mode-switching after a failed turn would lose + # the partial assistant response from the transcript. + if _stream_error and state.assistant_text: + if transcript_builder.last_entry_type != "assistant": + transcript_builder.append_assistant( + content_blocks=[{"type": "text", "text": state.assistant_text}], + model=active_model, + stop_reason=STOP_REASON_END_TURN, + ) + + if user_id and should_upload_transcript(user_id, transcript_covers_prefix): + await _upload_final_transcript( + user_id=user_id, + session_id=session_id, + transcript_builder=transcript_builder, + session_msg_count=len(session.messages), + ) + + # Clean up the ephemeral working directory used for file attachments. + if working_dir is not None: + shutil.rmtree(working_dir, ignore_errors=True) + # Yield usage and finish AFTER try/finally (not inside finally). # PEP 525 prohibits yielding from finally in async generators during # aclose() — doing so raises RuntimeError on client disconnect. diff --git a/autogpt_platform/backend/backend/copilot/baseline/service_unit_test.py b/autogpt_platform/backend/backend/copilot/baseline/service_unit_test.py new file mode 100644 index 0000000000..c5cbb9d882 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/baseline/service_unit_test.py @@ -0,0 +1,633 @@ +"""Unit tests for baseline service pure-logic helpers. + +These tests cover ``_baseline_conversation_updater`` and ``_BaselineStreamState`` +without requiring API keys, database connections, or network access. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from openai.types.chat import ChatCompletionToolParam + +from backend.copilot.baseline.service import ( + _baseline_conversation_updater, + _BaselineStreamState, + _compress_session_messages, + _ThinkingStripper, +) +from backend.copilot.model import ChatMessage +from backend.copilot.transcript_builder import TranscriptBuilder +from backend.util.prompt import CompressResult +from backend.util.tool_call_loop import LLMLoopResponse, LLMToolCall, ToolCallResult + + +class TestBaselineStreamState: + def test_defaults(self): + state = _BaselineStreamState() + assert state.pending_events == [] + assert state.assistant_text == "" + assert state.text_started is False + assert state.turn_prompt_tokens == 0 + assert state.turn_completion_tokens == 0 + assert state.text_block_id # Should be a UUID string + + def test_mutable_fields(self): + state = _BaselineStreamState() + state.assistant_text = "hello" + state.turn_prompt_tokens = 100 + state.turn_completion_tokens = 50 + assert state.assistant_text == "hello" + assert state.turn_prompt_tokens == 100 + assert state.turn_completion_tokens == 50 + + +class TestBaselineConversationUpdater: + """Tests for _baseline_conversation_updater which updates the OpenAI + message list and transcript builder after each LLM call.""" + + def _make_transcript_builder(self) -> TranscriptBuilder: + builder = TranscriptBuilder() + builder.append_user("test question") + return builder + + def test_text_only_response(self): + """When the LLM returns text without tool calls, the updater appends + a single assistant message and records it in the transcript.""" + messages: list = [] + builder = self._make_transcript_builder() + response = LLMLoopResponse( + response_text="Hello, world!", + tool_calls=[], + raw_response=None, + prompt_tokens=0, + completion_tokens=0, + ) + + _baseline_conversation_updater( + messages, + response, + tool_results=None, + transcript_builder=builder, + model="test-model", + ) + + assert len(messages) == 1 + assert messages[0]["role"] == "assistant" + assert messages[0]["content"] == "Hello, world!" + # Transcript should have user + assistant + assert builder.entry_count == 2 + assert builder.last_entry_type == "assistant" + + def test_tool_calls_response(self): + """When the LLM returns tool calls, the updater appends the assistant + message with tool_calls and tool result messages.""" + messages: list = [] + builder = self._make_transcript_builder() + response = LLMLoopResponse( + response_text="Let me search...", + tool_calls=[ + LLMToolCall( + id="tc_1", + name="search", + arguments='{"query": "test"}', + ), + ], + raw_response=None, + prompt_tokens=0, + completion_tokens=0, + ) + tool_results = [ + ToolCallResult( + tool_call_id="tc_1", + tool_name="search", + content="Found result", + ), + ] + + _baseline_conversation_updater( + messages, + response, + tool_results=tool_results, + transcript_builder=builder, + model="test-model", + ) + + # Messages: assistant (with tool_calls) + tool result + assert len(messages) == 2 + assert messages[0]["role"] == "assistant" + assert messages[0]["content"] == "Let me search..." + assert len(messages[0]["tool_calls"]) == 1 + assert messages[0]["tool_calls"][0]["id"] == "tc_1" + assert messages[1]["role"] == "tool" + assert messages[1]["tool_call_id"] == "tc_1" + assert messages[1]["content"] == "Found result" + + # Transcript: user + assistant(tool_use) + user(tool_result) + assert builder.entry_count == 3 + + def test_tool_calls_without_text(self): + """Tool calls without accompanying text should still work.""" + messages: list = [] + builder = self._make_transcript_builder() + response = LLMLoopResponse( + response_text=None, + tool_calls=[ + LLMToolCall(id="tc_1", name="run", arguments="{}"), + ], + raw_response=None, + prompt_tokens=0, + completion_tokens=0, + ) + tool_results = [ + ToolCallResult(tool_call_id="tc_1", tool_name="run", content="done"), + ] + + _baseline_conversation_updater( + messages, + response, + tool_results=tool_results, + transcript_builder=builder, + model="test-model", + ) + + assert len(messages) == 2 + assert "content" not in messages[0] # No text content + assert messages[0]["tool_calls"][0]["function"]["name"] == "run" + + def test_no_text_no_tools(self): + """When the response has no text and no tool calls, nothing is appended.""" + messages: list = [] + builder = self._make_transcript_builder() + response = LLMLoopResponse( + response_text=None, + tool_calls=[], + raw_response=None, + prompt_tokens=0, + completion_tokens=0, + ) + + _baseline_conversation_updater( + messages, + response, + tool_results=None, + transcript_builder=builder, + model="test-model", + ) + + assert len(messages) == 0 + # Only the user entry from setup + assert builder.entry_count == 1 + + def test_multiple_tool_calls(self): + """Multiple tool calls in a single response are all recorded.""" + messages: list = [] + builder = self._make_transcript_builder() + response = LLMLoopResponse( + response_text=None, + tool_calls=[ + LLMToolCall(id="tc_1", name="tool_a", arguments="{}"), + LLMToolCall(id="tc_2", name="tool_b", arguments='{"x": 1}'), + ], + raw_response=None, + prompt_tokens=0, + completion_tokens=0, + ) + tool_results = [ + ToolCallResult(tool_call_id="tc_1", tool_name="tool_a", content="result_a"), + ToolCallResult(tool_call_id="tc_2", tool_name="tool_b", content="result_b"), + ] + + _baseline_conversation_updater( + messages, + response, + tool_results=tool_results, + transcript_builder=builder, + model="test-model", + ) + + # 1 assistant + 2 tool results + assert len(messages) == 3 + assert len(messages[0]["tool_calls"]) == 2 + assert messages[1]["tool_call_id"] == "tc_1" + assert messages[2]["tool_call_id"] == "tc_2" + + def test_invalid_tool_arguments_handled(self): + """Tool call with invalid JSON arguments: the arguments field is + stored as-is in the message, and orjson failure falls back to {} + in the transcript content_blocks.""" + messages: list = [] + builder = self._make_transcript_builder() + response = LLMLoopResponse( + response_text=None, + tool_calls=[ + LLMToolCall(id="tc_1", name="tool_x", arguments="not-json"), + ], + raw_response=None, + prompt_tokens=0, + completion_tokens=0, + ) + tool_results = [ + ToolCallResult(tool_call_id="tc_1", tool_name="tool_x", content="ok"), + ] + + _baseline_conversation_updater( + messages, + response, + tool_results=tool_results, + transcript_builder=builder, + model="test-model", + ) + + # Should not raise — invalid JSON falls back to {} in transcript + assert len(messages) == 2 + assert messages[0]["tool_calls"][0]["function"]["arguments"] == "not-json" + + +class TestCompressSessionMessagesPreservesToolCalls: + """``_compress_session_messages`` must round-trip tool_calls + tool_call_id. + + Compression serialises ChatMessage to dict for ``compress_context`` and + reifies the result back to ChatMessage. A regression that drops + ``tool_calls`` or ``tool_call_id`` would corrupt the OpenAI message + list and break downstream tool-execution rounds. + """ + + @pytest.mark.asyncio + async def test_compressed_output_keeps_tool_calls_and_ids(self): + # Simulate compression that returns a summary + the most recent + # assistant(tool_call) + tool(tool_result) intact. + summary = {"role": "system", "content": "prior turns: user asked X"} + assistant_with_tc = { + "role": "assistant", + "content": "calling tool", + "tool_calls": [ + { + "id": "tc_abc", + "type": "function", + "function": {"name": "search", "arguments": '{"q":"y"}'}, + } + ], + } + tool_result = { + "role": "tool", + "tool_call_id": "tc_abc", + "content": "search result", + } + + compress_result = CompressResult( + messages=[summary, assistant_with_tc, tool_result], + token_count=100, + was_compacted=True, + original_token_count=5000, + messages_summarized=10, + messages_dropped=0, + ) + + # Input: messages that should be compressed. + input_messages = [ + ChatMessage(role="user", content="q1"), + ChatMessage( + role="assistant", + content="calling tool", + tool_calls=[ + { + "id": "tc_abc", + "type": "function", + "function": { + "name": "search", + "arguments": '{"q":"y"}', + }, + } + ], + ), + ChatMessage( + role="tool", + tool_call_id="tc_abc", + content="search result", + ), + ] + + with patch( + "backend.copilot.baseline.service.compress_context", + new=AsyncMock(return_value=compress_result), + ): + compressed = await _compress_session_messages( + input_messages, model="openrouter/anthropic/claude-opus-4" + ) + + # Summary, assistant(tool_calls), tool(tool_call_id). + assert len(compressed) == 3 + # Assistant message must keep its tool_calls intact. + assistant_msg = compressed[1] + assert assistant_msg.role == "assistant" + assert assistant_msg.tool_calls is not None + assert len(assistant_msg.tool_calls) == 1 + assert assistant_msg.tool_calls[0]["id"] == "tc_abc" + assert assistant_msg.tool_calls[0]["function"]["name"] == "search" + # Tool-role message must keep tool_call_id for OpenAI linkage. + tool_msg = compressed[2] + assert tool_msg.role == "tool" + assert tool_msg.tool_call_id == "tc_abc" + assert tool_msg.content == "search result" + + @pytest.mark.asyncio + async def test_uncompressed_passthrough_keeps_fields(self): + """When compression is a no-op (was_compacted=False), the original + messages must be returned unchanged — including tool_calls.""" + input_messages = [ + ChatMessage( + role="assistant", + content="c", + tool_calls=[ + { + "id": "t1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ), + ChatMessage(role="tool", tool_call_id="t1", content="ok"), + ] + + noop_result = CompressResult( + messages=[], # ignored when was_compacted=False + token_count=10, + was_compacted=False, + ) + + with patch( + "backend.copilot.baseline.service.compress_context", + new=AsyncMock(return_value=noop_result), + ): + out = await _compress_session_messages( + input_messages, model="openrouter/anthropic/claude-opus-4" + ) + + assert out is input_messages # same list returned + assert out[0].tool_calls is not None + assert out[0].tool_calls[0]["id"] == "t1" + assert out[1].tool_call_id == "t1" + + +# ---- _ThinkingStripper tests ---- # + + +def test_thinking_stripper_basic_thinking_tag() -> None: + """... blocks are fully stripped.""" + s = _ThinkingStripper() + assert s.process("internal reasoning hereHello!") == "Hello!" + + +def test_thinking_stripper_internal_reasoning_tag() -> None: + """... blocks (Gemini) are stripped.""" + s = _ThinkingStripper() + assert ( + s.process("step by stepAnswer") + == "Answer" + ) + + +def test_thinking_stripper_split_across_chunks() -> None: + """Tags split across multiple chunks are handled correctly.""" + s = _ThinkingStripper() + out = s.process("Hello secret world") + assert out == "Hello world" + + +def test_thinking_stripper_plain_text_preserved() -> None: + """Plain text with the word 'thinking' is not stripped.""" + s = _ThinkingStripper() + assert ( + s.process("I am thinking about this problem") + == "I am thinking about this problem" + ) + + +def test_thinking_stripper_multiple_blocks() -> None: + """Multiple reasoning blocks in one stream are all stripped.""" + s = _ThinkingStripper() + result = s.process( + "AxByC" + ) + assert result == "ABC" + + +def test_thinking_stripper_flush_discards_unclosed() -> None: + """Unclosed reasoning block is discarded on flush.""" + s = _ThinkingStripper() + s.process("Startnever closed") + flushed = s.flush() + assert "never closed" not in flushed + + +def test_thinking_stripper_empty_block() -> None: + """Empty reasoning blocks are handled gracefully.""" + s = _ThinkingStripper() + assert s.process("BeforeAfter") == "BeforeAfter" + + +# ---- _filter_tools_by_permissions tests ---- # + + +def _make_tool(name: str) -> ChatCompletionToolParam: + """Build a minimal OpenAI ChatCompletionToolParam.""" + return ChatCompletionToolParam( + type="function", + function={"name": name, "parameters": {}}, + ) + + +class TestFilterToolsByPermissions: + """Tests for _filter_tools_by_permissions.""" + + @patch( + "backend.copilot.permissions.all_known_tool_names", + return_value=frozenset({"run_block", "web_fetch", "bash_exec"}), + ) + def test_empty_permissions_returns_all(self, _mock_names): + """Empty permissions (no filtering) returns every tool unchanged.""" + from backend.copilot.baseline.service import _filter_tools_by_permissions + from backend.copilot.permissions import CopilotPermissions + + tools = [_make_tool("run_block"), _make_tool("web_fetch")] + perms = CopilotPermissions() + result = _filter_tools_by_permissions(tools, perms) + assert result == tools + + @patch( + "backend.copilot.permissions.all_known_tool_names", + return_value=frozenset({"run_block", "web_fetch", "bash_exec"}), + ) + def test_allowlist_keeps_only_matching(self, _mock_names): + """Explicit allowlist (tools_exclude=False) keeps only listed tools.""" + from backend.copilot.baseline.service import _filter_tools_by_permissions + from backend.copilot.permissions import CopilotPermissions + + tools = [ + _make_tool("run_block"), + _make_tool("web_fetch"), + _make_tool("bash_exec"), + ] + perms = CopilotPermissions(tools=["web_fetch"], tools_exclude=False) + result = _filter_tools_by_permissions(tools, perms) + assert len(result) == 1 + assert result[0]["function"]["name"] == "web_fetch" + + @patch( + "backend.copilot.permissions.all_known_tool_names", + return_value=frozenset({"run_block", "web_fetch", "bash_exec"}), + ) + def test_blacklist_excludes_listed(self, _mock_names): + """Blacklist (tools_exclude=True) removes only the listed tools.""" + from backend.copilot.baseline.service import _filter_tools_by_permissions + from backend.copilot.permissions import CopilotPermissions + + tools = [ + _make_tool("run_block"), + _make_tool("web_fetch"), + _make_tool("bash_exec"), + ] + perms = CopilotPermissions(tools=["bash_exec"], tools_exclude=True) + result = _filter_tools_by_permissions(tools, perms) + names = [t["function"]["name"] for t in result] + assert "bash_exec" not in names + assert "run_block" in names + assert "web_fetch" in names + assert len(result) == 2 + + @patch( + "backend.copilot.permissions.all_known_tool_names", + return_value=frozenset({"run_block", "web_fetch", "bash_exec"}), + ) + def test_unknown_tool_name_filtered_out(self, _mock_names): + """A tool whose name is not in all_known_tool_names is dropped.""" + from backend.copilot.baseline.service import _filter_tools_by_permissions + from backend.copilot.permissions import CopilotPermissions + + tools = [_make_tool("run_block"), _make_tool("unknown_tool")] + perms = CopilotPermissions(tools=["run_block"], tools_exclude=False) + result = _filter_tools_by_permissions(tools, perms) + names = [t["function"]["name"] for t in result] + assert "unknown_tool" not in names + assert names == ["run_block"] + + +# ---- _prepare_baseline_attachments tests ---- # + + +class TestPrepareBaselineAttachments: + """Tests for _prepare_baseline_attachments.""" + + @pytest.mark.asyncio + async def test_empty_file_ids(self): + """Empty file_ids returns empty hint and blocks.""" + from backend.copilot.baseline.service import _prepare_baseline_attachments + + hint, blocks = await _prepare_baseline_attachments([], "user1", "sess1", "/tmp") + assert hint == "" + assert blocks == [] + + @pytest.mark.asyncio + async def test_empty_user_id(self): + """Empty user_id returns empty hint and blocks.""" + from backend.copilot.baseline.service import _prepare_baseline_attachments + + hint, blocks = await _prepare_baseline_attachments( + ["file1"], "", "sess1", "/tmp" + ) + assert hint == "" + assert blocks == [] + + @pytest.mark.asyncio + async def test_image_file_returns_vision_blocks(self): + """A PNG image within size limits is returned as a base64 vision block.""" + from backend.copilot.baseline.service import _prepare_baseline_attachments + + fake_info = AsyncMock() + fake_info.name = "photo.png" + fake_info.mime_type = "image/png" + fake_info.size_bytes = 1024 + + fake_manager = AsyncMock() + fake_manager.get_file_info = AsyncMock(return_value=fake_info) + fake_manager.read_file_by_id = AsyncMock(return_value=b"\x89PNG_FAKE_DATA") + + with patch( + "backend.copilot.baseline.service.get_workspace_manager", + new=AsyncMock(return_value=fake_manager), + ): + hint, blocks = await _prepare_baseline_attachments( + ["fid1"], "user1", "sess1", "/tmp/workdir" + ) + + assert len(blocks) == 1 + assert blocks[0]["type"] == "image" + assert blocks[0]["source"]["media_type"] == "image/png" + assert blocks[0]["source"]["type"] == "base64" + assert "photo.png" in hint + assert "embedded as image" in hint + + @pytest.mark.asyncio + async def test_non_image_file_saved_to_working_dir(self, tmp_path): + """A non-image file is written to working_dir.""" + from backend.copilot.baseline.service import _prepare_baseline_attachments + + fake_info = AsyncMock() + fake_info.name = "data.csv" + fake_info.mime_type = "text/csv" + fake_info.size_bytes = 42 + + fake_manager = AsyncMock() + fake_manager.get_file_info = AsyncMock(return_value=fake_info) + fake_manager.read_file_by_id = AsyncMock(return_value=b"col1,col2\na,b") + + with patch( + "backend.copilot.baseline.service.get_workspace_manager", + new=AsyncMock(return_value=fake_manager), + ): + hint, blocks = await _prepare_baseline_attachments( + ["fid1"], "user1", "sess1", str(tmp_path) + ) + + assert blocks == [] + assert "data.csv" in hint + assert "saved to" in hint + saved = tmp_path / "data.csv" + assert saved.exists() + assert saved.read_bytes() == b"col1,col2\na,b" + + @pytest.mark.asyncio + async def test_file_not_found_skipped(self): + """When get_file_info returns None the file is silently skipped.""" + from backend.copilot.baseline.service import _prepare_baseline_attachments + + fake_manager = AsyncMock() + fake_manager.get_file_info = AsyncMock(return_value=None) + + with patch( + "backend.copilot.baseline.service.get_workspace_manager", + new=AsyncMock(return_value=fake_manager), + ): + hint, blocks = await _prepare_baseline_attachments( + ["missing_id"], "user1", "sess1", "/tmp" + ) + + assert hint == "" + assert blocks == [] + + @pytest.mark.asyncio + async def test_workspace_manager_error(self): + """When get_workspace_manager raises, returns empty results.""" + from backend.copilot.baseline.service import _prepare_baseline_attachments + + with patch( + "backend.copilot.baseline.service.get_workspace_manager", + new=AsyncMock(side_effect=RuntimeError("connection failed")), + ): + hint, blocks = await _prepare_baseline_attachments( + ["fid1"], "user1", "sess1", "/tmp" + ) + + assert hint == "" + assert blocks == [] diff --git a/autogpt_platform/backend/backend/copilot/baseline/transcript_integration_test.py b/autogpt_platform/backend/backend/copilot/baseline/transcript_integration_test.py new file mode 100644 index 0000000000..fccf7c6387 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/baseline/transcript_integration_test.py @@ -0,0 +1,667 @@ +"""Integration tests for baseline transcript flow. + +Exercises the real helpers in ``baseline/service.py`` that download, +validate, load, append to, backfill, and upload the transcript. +Storage is mocked via ``download_transcript`` / ``upload_transcript`` +patches; no network access is required. +""" + +import json as stdlib_json +from unittest.mock import AsyncMock, patch + +import pytest + +from backend.copilot.baseline.service import ( + _load_prior_transcript, + _record_turn_to_transcript, + _resolve_baseline_model, + _upload_final_transcript, + is_transcript_stale, + should_upload_transcript, +) +from backend.copilot.service import config +from backend.copilot.transcript import ( + STOP_REASON_END_TURN, + STOP_REASON_TOOL_USE, + TranscriptDownload, +) +from backend.copilot.transcript_builder import TranscriptBuilder +from backend.util.tool_call_loop import LLMLoopResponse, LLMToolCall, ToolCallResult + + +def _make_transcript_content(*roles: str) -> str: + """Build a minimal valid JSONL transcript from role names.""" + lines = [] + parent = "" + for i, role in enumerate(roles): + uid = f"uuid-{i}" + entry: dict = { + "type": role, + "uuid": uid, + "parentUuid": parent, + "message": { + "role": role, + "content": [{"type": "text", "text": f"{role} message {i}"}], + }, + } + if role == "assistant": + entry["message"]["id"] = f"msg_{i}" + entry["message"]["model"] = "test-model" + entry["message"]["type"] = "message" + entry["message"]["stop_reason"] = STOP_REASON_END_TURN + lines.append(stdlib_json.dumps(entry)) + parent = uid + return "\n".join(lines) + "\n" + + +class TestResolveBaselineModel: + """Model selection honours the per-request mode.""" + + def test_fast_mode_selects_fast_model(self): + assert _resolve_baseline_model("fast") == config.fast_model + + def test_extended_thinking_selects_default_model(self): + assert _resolve_baseline_model("extended_thinking") == config.model + + def test_none_mode_selects_default_model(self): + """Critical: baseline users without a mode MUST keep the default (opus).""" + assert _resolve_baseline_model(None) == config.model + + def test_default_and_fast_models_differ(self): + """Sanity: the two tiers are actually distinct in production config.""" + assert config.model != config.fast_model + + +class TestLoadPriorTranscript: + """``_load_prior_transcript`` wraps the download + validate + load flow.""" + + @pytest.mark.asyncio + async def test_loads_fresh_transcript(self): + builder = TranscriptBuilder() + content = _make_transcript_content("user", "assistant") + download = TranscriptDownload(content=content, message_count=2) + + with patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=download), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=3, + transcript_builder=builder, + ) + + assert covers is True + assert builder.entry_count == 2 + assert builder.last_entry_type == "assistant" + + @pytest.mark.asyncio + async def test_rejects_stale_transcript(self): + """msg_count strictly less than session-1 is treated as stale.""" + builder = TranscriptBuilder() + content = _make_transcript_content("user", "assistant") + # session has 6 messages, transcript only covers 2 → stale. + download = TranscriptDownload(content=content, message_count=2) + + with patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=download), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=6, + transcript_builder=builder, + ) + + assert covers is False + assert builder.is_empty + + @pytest.mark.asyncio + async def test_missing_transcript_returns_false(self): + builder = TranscriptBuilder() + with patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=None), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=2, + transcript_builder=builder, + ) + + assert covers is False + assert builder.is_empty + + @pytest.mark.asyncio + async def test_invalid_transcript_returns_false(self): + builder = TranscriptBuilder() + download = TranscriptDownload( + content='{"type":"progress","uuid":"a"}\n', + message_count=1, + ) + with patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=download), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=2, + transcript_builder=builder, + ) + + assert covers is False + assert builder.is_empty + + @pytest.mark.asyncio + async def test_download_exception_returns_false(self): + builder = TranscriptBuilder() + with patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(side_effect=RuntimeError("boom")), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=2, + transcript_builder=builder, + ) + + assert covers is False + assert builder.is_empty + + @pytest.mark.asyncio + async def test_zero_message_count_not_stale(self): + """When msg_count is 0 (unknown), staleness check is skipped.""" + builder = TranscriptBuilder() + download = TranscriptDownload( + content=_make_transcript_content("user", "assistant"), + message_count=0, + ) + with patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=download), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=20, + transcript_builder=builder, + ) + + assert covers is True + assert builder.entry_count == 2 + + +class TestUploadFinalTranscript: + """``_upload_final_transcript`` serialises and calls storage.""" + + @pytest.mark.asyncio + async def test_uploads_valid_transcript(self): + builder = TranscriptBuilder() + builder.append_user(content="hi") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "hello"}], + model="test-model", + stop_reason=STOP_REASON_END_TURN, + ) + + upload_mock = AsyncMock(return_value=None) + with patch( + "backend.copilot.baseline.service.upload_transcript", + new=upload_mock, + ): + await _upload_final_transcript( + user_id="user-1", + session_id="session-1", + transcript_builder=builder, + session_msg_count=2, + ) + + upload_mock.assert_awaited_once() + assert upload_mock.await_args is not None + call_kwargs = upload_mock.await_args.kwargs + assert call_kwargs["user_id"] == "user-1" + assert call_kwargs["session_id"] == "session-1" + assert call_kwargs["message_count"] == 2 + assert "hello" in call_kwargs["content"] + + @pytest.mark.asyncio + async def test_skips_upload_when_builder_empty(self): + builder = TranscriptBuilder() + upload_mock = AsyncMock(return_value=None) + with patch( + "backend.copilot.baseline.service.upload_transcript", + new=upload_mock, + ): + await _upload_final_transcript( + user_id="user-1", + session_id="session-1", + transcript_builder=builder, + session_msg_count=0, + ) + + upload_mock.assert_not_awaited() + + @pytest.mark.asyncio + async def test_swallows_upload_exceptions(self): + """Upload failures should not propagate (flow continues for the user).""" + builder = TranscriptBuilder() + builder.append_user(content="hi") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "hello"}], + model="test-model", + stop_reason=STOP_REASON_END_TURN, + ) + + with patch( + "backend.copilot.baseline.service.upload_transcript", + new=AsyncMock(side_effect=RuntimeError("storage unavailable")), + ): + # Should not raise. + await _upload_final_transcript( + user_id="user-1", + session_id="session-1", + transcript_builder=builder, + session_msg_count=2, + ) + + +class TestRecordTurnToTranscript: + """``_record_turn_to_transcript`` translates LLMLoopResponse → transcript.""" + + def test_records_final_assistant_text(self): + builder = TranscriptBuilder() + builder.append_user(content="hi") + + response = LLMLoopResponse( + response_text="hello there", + tool_calls=[], + raw_response=None, + ) + _record_turn_to_transcript( + response, + tool_results=None, + transcript_builder=builder, + model="test-model", + ) + + assert builder.entry_count == 2 + assert builder.last_entry_type == "assistant" + jsonl = builder.to_jsonl() + assert "hello there" in jsonl + assert STOP_REASON_END_TURN in jsonl + + def test_records_tool_use_then_tool_result(self): + """Anthropic ordering: assistant(tool_use) → user(tool_result).""" + builder = TranscriptBuilder() + builder.append_user(content="use a tool") + + response = LLMLoopResponse( + response_text=None, + tool_calls=[ + LLMToolCall(id="call-1", name="echo", arguments='{"text":"hi"}') + ], + raw_response=None, + ) + tool_results = [ + ToolCallResult(tool_call_id="call-1", tool_name="echo", content="hi") + ] + _record_turn_to_transcript( + response, + tool_results, + transcript_builder=builder, + model="test-model", + ) + + # user, assistant(tool_use), user(tool_result) = 3 entries + assert builder.entry_count == 3 + jsonl = builder.to_jsonl() + assert STOP_REASON_TOOL_USE in jsonl + assert "tool_use" in jsonl + assert "tool_result" in jsonl + assert "call-1" in jsonl + + def test_records_nothing_on_empty_response(self): + builder = TranscriptBuilder() + builder.append_user(content="hi") + + response = LLMLoopResponse( + response_text=None, + tool_calls=[], + raw_response=None, + ) + _record_turn_to_transcript( + response, + tool_results=None, + transcript_builder=builder, + model="test-model", + ) + + assert builder.entry_count == 1 + + def test_malformed_tool_args_dont_crash(self): + """Bad JSON in tool arguments falls back to {} without raising.""" + builder = TranscriptBuilder() + builder.append_user(content="hi") + + response = LLMLoopResponse( + response_text=None, + tool_calls=[LLMToolCall(id="call-1", name="echo", arguments="{not-json")], + raw_response=None, + ) + tool_results = [ + ToolCallResult(tool_call_id="call-1", tool_name="echo", content="ok") + ] + _record_turn_to_transcript( + response, + tool_results, + transcript_builder=builder, + model="test-model", + ) + + assert builder.entry_count == 3 + jsonl = builder.to_jsonl() + assert '"input":{}' in jsonl + + +class TestRoundTrip: + """End-to-end: load prior → append new turn → upload.""" + + @pytest.mark.asyncio + async def test_full_round_trip(self): + prior = _make_transcript_content("user", "assistant") + download = TranscriptDownload(content=prior, message_count=2) + + builder = TranscriptBuilder() + with patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=download), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=3, + transcript_builder=builder, + ) + assert covers is True + assert builder.entry_count == 2 + + # New user turn. + builder.append_user(content="new question") + assert builder.entry_count == 3 + + # New assistant turn. + response = LLMLoopResponse( + response_text="new answer", + tool_calls=[], + raw_response=None, + ) + _record_turn_to_transcript( + response, + tool_results=None, + transcript_builder=builder, + model="test-model", + ) + assert builder.entry_count == 4 + + # Upload. + upload_mock = AsyncMock(return_value=None) + with patch( + "backend.copilot.baseline.service.upload_transcript", + new=upload_mock, + ): + await _upload_final_transcript( + user_id="user-1", + session_id="session-1", + transcript_builder=builder, + session_msg_count=4, + ) + + upload_mock.assert_awaited_once() + assert upload_mock.await_args is not None + uploaded = upload_mock.await_args.kwargs["content"] + assert "new question" in uploaded + assert "new answer" in uploaded + # Original content preserved in the round trip. + assert "user message 0" in uploaded + assert "assistant message 1" in uploaded + + @pytest.mark.asyncio + async def test_backfill_append_guard(self): + """Backfill only runs when the last entry is not already assistant.""" + builder = TranscriptBuilder() + builder.append_user(content="hi") + + # Simulate the backfill guard from stream_chat_completion_baseline. + assistant_text = "partial text before error" + if builder.last_entry_type != "assistant": + builder.append_assistant( + content_blocks=[{"type": "text", "text": assistant_text}], + model="test-model", + stop_reason=STOP_REASON_END_TURN, + ) + + assert builder.last_entry_type == "assistant" + assert "partial text before error" in builder.to_jsonl() + + # Second invocation: the guard must prevent double-append. + initial_count = builder.entry_count + if builder.last_entry_type != "assistant": + builder.append_assistant( + content_blocks=[{"type": "text", "text": "duplicate"}], + model="test-model", + stop_reason=STOP_REASON_END_TURN, + ) + assert builder.entry_count == initial_count + + +class TestIsTranscriptStale: + """``is_transcript_stale`` gates prior-transcript loading.""" + + def test_none_download_is_not_stale(self): + assert is_transcript_stale(None, session_msg_count=5) is False + + def test_zero_message_count_is_not_stale(self): + """Legacy transcripts without msg_count tracking must remain usable.""" + dl = TranscriptDownload(content="", message_count=0) + assert is_transcript_stale(dl, session_msg_count=20) is False + + def test_stale_when_covers_less_than_prefix(self): + dl = TranscriptDownload(content="", message_count=2) + # session has 6 messages; transcript must cover at least 5 (6-1). + assert is_transcript_stale(dl, session_msg_count=6) is True + + def test_fresh_when_covers_full_prefix(self): + dl = TranscriptDownload(content="", message_count=5) + assert is_transcript_stale(dl, session_msg_count=6) is False + + def test_fresh_when_exceeds_prefix(self): + """Race: transcript ahead of session count is still acceptable.""" + dl = TranscriptDownload(content="", message_count=10) + assert is_transcript_stale(dl, session_msg_count=6) is False + + def test_boundary_equal_to_prefix_minus_one(self): + dl = TranscriptDownload(content="", message_count=5) + assert is_transcript_stale(dl, session_msg_count=6) is False + + +class TestShouldUploadTranscript: + """``should_upload_transcript`` gates the final upload.""" + + def test_upload_allowed_for_user_with_coverage(self): + assert should_upload_transcript("user-1", True) is True + + def test_upload_skipped_when_no_user(self): + assert should_upload_transcript(None, True) is False + + def test_upload_skipped_when_empty_user(self): + assert should_upload_transcript("", True) is False + + def test_upload_skipped_without_coverage(self): + """Partial transcript must never clobber a more complete stored one.""" + assert should_upload_transcript("user-1", False) is False + + def test_upload_skipped_when_no_user_and_no_coverage(self): + assert should_upload_transcript(None, False) is False + + +class TestTranscriptLifecycle: + """End-to-end: download → validate → build → upload. + + Simulates the full transcript lifecycle inside + ``stream_chat_completion_baseline`` by mocking the storage layer and + driving each step through the real helpers. + """ + + @pytest.mark.asyncio + async def test_full_lifecycle_happy_path(self): + """Fresh download, append a turn, upload covers the session.""" + builder = TranscriptBuilder() + prior = _make_transcript_content("user", "assistant") + download = TranscriptDownload(content=prior, message_count=2) + + upload_mock = AsyncMock(return_value=None) + with ( + patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=download), + ), + patch( + "backend.copilot.baseline.service.upload_transcript", + new=upload_mock, + ), + ): + # --- 1. Download & load prior transcript --- + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=3, + transcript_builder=builder, + ) + assert covers is True + + # --- 2. Append a new user turn + a new assistant response --- + builder.append_user(content="follow-up question") + _record_turn_to_transcript( + LLMLoopResponse( + response_text="follow-up answer", + tool_calls=[], + raw_response=None, + ), + tool_results=None, + transcript_builder=builder, + model="test-model", + ) + + # --- 3. Gate + upload --- + assert ( + should_upload_transcript( + user_id="user-1", transcript_covers_prefix=covers + ) + is True + ) + await _upload_final_transcript( + user_id="user-1", + session_id="session-1", + transcript_builder=builder, + session_msg_count=4, + ) + + upload_mock.assert_awaited_once() + assert upload_mock.await_args is not None + uploaded = upload_mock.await_args.kwargs["content"] + assert "follow-up question" in uploaded + assert "follow-up answer" in uploaded + # Original prior-turn content preserved. + assert "user message 0" in uploaded + assert "assistant message 1" in uploaded + + @pytest.mark.asyncio + async def test_lifecycle_stale_download_suppresses_upload(self): + """Stale download → covers=False → upload must be skipped.""" + builder = TranscriptBuilder() + # session has 10 msgs but stored transcript only covers 2 → stale. + stale = TranscriptDownload( + content=_make_transcript_content("user", "assistant"), + message_count=2, + ) + + upload_mock = AsyncMock(return_value=None) + with ( + patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=stale), + ), + patch( + "backend.copilot.baseline.service.upload_transcript", + new=upload_mock, + ), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=10, + transcript_builder=builder, + ) + + assert covers is False + # The caller's gate mirrors the production path. + assert ( + should_upload_transcript(user_id="user-1", transcript_covers_prefix=covers) + is False + ) + upload_mock.assert_not_awaited() + + @pytest.mark.asyncio + async def test_lifecycle_anonymous_user_skips_upload(self): + """Anonymous (user_id=None) → upload gate must return False.""" + builder = TranscriptBuilder() + builder.append_user(content="hi") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "hello"}], + model="test-model", + stop_reason=STOP_REASON_END_TURN, + ) + + assert ( + should_upload_transcript(user_id=None, transcript_covers_prefix=True) + is False + ) + + @pytest.mark.asyncio + async def test_lifecycle_missing_download_still_uploads_new_content(self): + """No prior transcript → covers defaults to True in the service, + new turn should upload cleanly.""" + builder = TranscriptBuilder() + upload_mock = AsyncMock(return_value=None) + with ( + patch( + "backend.copilot.baseline.service.download_transcript", + new=AsyncMock(return_value=None), + ), + patch( + "backend.copilot.baseline.service.upload_transcript", + new=upload_mock, + ), + ): + covers = await _load_prior_transcript( + user_id="user-1", + session_id="session-1", + session_msg_count=1, + transcript_builder=builder, + ) + # No download: covers is False, so the production path would + # skip upload. This protects against overwriting a future + # more-complete transcript with a single-turn snapshot. + assert covers is False + assert ( + should_upload_transcript( + user_id="user-1", transcript_covers_prefix=covers + ) + is False + ) + upload_mock.assert_not_awaited() diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 6c271322a6..2db5c2f03f 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -8,13 +8,26 @@ from pydantic_settings import BaseSettings from backend.util.clients import OPENROUTER_BASE_URL +# Per-request routing mode for a single chat turn. +# - 'fast': route to the baseline OpenAI-compatible path with the cheaper model. +# - 'extended_thinking': route to the Claude Agent SDK path with the default +# (opus) model. +# ``None`` means "no override"; the server falls back to the Claude Code +# subscription flag → LaunchDarkly COPILOT_SDK → config.use_claude_agent_sdk. +CopilotMode = Literal["fast", "extended_thinking"] + class ChatConfig(BaseSettings): """Configuration for the chat system.""" # OpenAI API Configuration model: str = Field( - default="anthropic/claude-opus-4.6", description="Default model to use" + default="anthropic/claude-opus-4.6", + description="Default model for extended thinking mode", + ) + fast_model: str = Field( + default="anthropic/claude-sonnet-4", + description="Model for fast mode (baseline path). Should be faster/cheaper than the default model.", ) title_model: str = Field( default="openai/gpt-4o-mini", diff --git a/autogpt_platform/backend/backend/copilot/db.py b/autogpt_platform/backend/backend/copilot/db.py index f94f1d56c7..24d0e1a558 100644 --- a/autogpt_platform/backend/backend/copilot/db.py +++ b/autogpt_platform/backend/backend/copilot/db.py @@ -14,6 +14,7 @@ from prisma.types import ( ChatSessionUpdateInput, ChatSessionWhereInput, ) +from pydantic import BaseModel from backend.data import db from backend.util.json import SafeJson, sanitize_string @@ -23,12 +24,22 @@ from .model import ( ChatSession, ChatSessionInfo, ChatSessionMetadata, - invalidate_session_cache, + cache_chat_session, ) +from .model import get_chat_session as get_chat_session_cached logger = logging.getLogger(__name__) +class PaginatedMessages(BaseModel): + """Result of a paginated message query.""" + + messages: list[ChatMessage] + has_more: bool + oldest_sequence: int | None + session: ChatSessionInfo + + async def get_chat_session(session_id: str) -> ChatSession | None: """Get a chat session by ID from the database.""" session = await PrismaChatSession.prisma().find_unique( @@ -38,6 +49,116 @@ async def get_chat_session(session_id: str) -> ChatSession | None: return ChatSession.from_db(session) if session else None +async def get_chat_session_metadata(session_id: str) -> ChatSessionInfo | None: + """Get chat session metadata (without messages) for ownership validation.""" + session = await PrismaChatSession.prisma().find_unique( + where={"id": session_id}, + ) + return ChatSessionInfo.from_db(session) if session else None + + +async def get_chat_messages_paginated( + session_id: str, + limit: int = 50, + before_sequence: int | None = None, + user_id: str | None = None, +) -> PaginatedMessages | None: + """Get paginated messages for a session, newest first. + + Verifies session existence (and ownership when ``user_id`` is provided) + in parallel with the message query. Returns ``None`` when the session + is not found or does not belong to the user. + + Args: + session_id: The chat session ID. + limit: Max messages to return. + before_sequence: Cursor — return messages with sequence < this value. + user_id: If provided, filters via ``Session.userId`` so only the + session owner's messages are returned (acts as an ownership guard). + """ + # Build session-existence / ownership check + session_where: ChatSessionWhereInput = {"id": session_id} + if user_id is not None: + session_where["userId"] = user_id + + # Build message include — fetch paginated messages in the same query + msg_include: dict[str, Any] = { + "order_by": {"sequence": "desc"}, + "take": limit + 1, + } + if before_sequence is not None: + msg_include["where"] = {"sequence": {"lt": before_sequence}} + + # Single query: session existence/ownership + paginated messages + session = await PrismaChatSession.prisma().find_first( + where=session_where, + include={"Messages": msg_include}, + ) + + if session is None: + return None + + session_info = ChatSessionInfo.from_db(session) + results = list(session.Messages) if session.Messages else [] + + has_more = len(results) > limit + results = results[:limit] + + # Reverse to ascending order + results.reverse() + + # Tool-call boundary fix: if the oldest message is a tool message, + # expand backward to include the preceding assistant message that + # owns the tool_calls, so convertChatSessionMessagesToUiMessages + # can pair them correctly. + _BOUNDARY_SCAN_LIMIT = 10 + if results and results[0].role == "tool": + boundary_where: dict[str, Any] = { + "sessionId": session_id, + "sequence": {"lt": results[0].sequence}, + } + if user_id is not None: + boundary_where["Session"] = {"is": {"userId": user_id}} + extra = await PrismaChatMessage.prisma().find_many( + where=boundary_where, + order={"sequence": "desc"}, + take=_BOUNDARY_SCAN_LIMIT, + ) + # Find the first non-tool message (should be the assistant) + boundary_msgs = [] + found_owner = False + for msg in extra: + boundary_msgs.append(msg) + if msg.role != "tool": + found_owner = True + break + boundary_msgs.reverse() + if not found_owner: + logger.warning( + "Boundary expansion did not find owning assistant message " + "for session=%s before sequence=%s (%d msgs scanned)", + session_id, + results[0].sequence, + len(extra), + ) + if boundary_msgs: + results = boundary_msgs + results + # Only mark has_more if the expanded boundary isn't the + # very start of the conversation (sequence 0). + if boundary_msgs[0].sequence > 0: + has_more = True + + messages = [ChatMessage.from_db(m) for m in results] + oldest_sequence = messages[0].sequence if messages else None + + return PaginatedMessages( + messages=messages, + has_more=has_more, + oldest_sequence=oldest_sequence, + session=session_info, + ) + + async def create_chat_session( session_id: str, user_id: str, @@ -386,8 +507,11 @@ async def update_tool_message_content( async def set_turn_duration(session_id: str, duration_ms: int) -> None: """Set durationMs on the last assistant message in a session. - Also invalidates the Redis session cache so the next GET returns - the updated duration. + Updates the Redis cache in-place instead of invalidating it. + Invalidation would delete the key, creating a window where concurrent + ``get_chat_session`` calls re-populate the cache from DB — potentially + with stale data if the DB write from the previous turn hasn't propagated. + This race caused duplicate user messages on the next turn. """ last_msg = await PrismaChatMessage.prisma().find_first( where={"sessionId": session_id, "role": "assistant"}, @@ -398,5 +522,13 @@ async def set_turn_duration(session_id: str, duration_ms: int) -> None: where={"id": last_msg.id}, data={"durationMs": duration_ms}, ) - # Invalidate cache so the session is re-fetched from DB with durationMs - await invalidate_session_cache(session_id) + # Update cache in-place rather than invalidating to avoid a + # race window where the empty cache gets re-populated with + # stale data by a concurrent get_chat_session call. + session = await get_chat_session_cached(session_id) + if session and session.messages: + for msg in reversed(session.messages): + if msg.role == "assistant": + msg.duration_ms = duration_ms + break + await cache_chat_session(session) diff --git a/autogpt_platform/backend/backend/copilot/db_test.py b/autogpt_platform/backend/backend/copilot/db_test.py new file mode 100644 index 0000000000..27fa788702 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/db_test.py @@ -0,0 +1,388 @@ +"""Unit tests for copilot.db — paginated message queries.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from prisma.models import ChatMessage as PrismaChatMessage +from prisma.models import ChatSession as PrismaChatSession + +from backend.copilot.db import ( + PaginatedMessages, + get_chat_messages_paginated, + set_turn_duration, +) +from backend.copilot.model import ChatMessage as CopilotChatMessage +from backend.copilot.model import ChatSession, get_chat_session, upsert_chat_session + + +def _make_msg( + sequence: int, + role: str = "assistant", + content: str | None = "hello", + tool_calls: Any = None, +) -> PrismaChatMessage: + """Build a minimal PrismaChatMessage for testing.""" + return PrismaChatMessage( + id=f"msg-{sequence}", + createdAt=datetime.now(UTC), + sessionId="sess-1", + role=role, + content=content, + sequence=sequence, + toolCalls=tool_calls, + name=None, + toolCallId=None, + refusal=None, + functionCall=None, + ) + + +def _make_session( + session_id: str = "sess-1", + user_id: str = "user-1", + messages: list[PrismaChatMessage] | None = None, +) -> PrismaChatSession: + """Build a minimal PrismaChatSession for testing.""" + now = datetime.now(UTC) + session = PrismaChatSession.model_construct( + id=session_id, + createdAt=now, + updatedAt=now, + userId=user_id, + credentials={}, + successfulAgentRuns={}, + successfulAgentSchedules={}, + totalPromptTokens=0, + totalCompletionTokens=0, + title=None, + metadata={}, + Messages=messages or [], + ) + return session + + +SESSION_ID = "sess-1" + + +@pytest.fixture() +def mock_db(): + """Patch ChatSession.prisma().find_first and ChatMessage.prisma().find_many. + + find_first is used for the main query (session + included messages). + find_many is used only for boundary expansion queries. + """ + with ( + patch.object(PrismaChatSession, "prisma") as mock_session_prisma, + patch.object(PrismaChatMessage, "prisma") as mock_msg_prisma, + ): + find_first = AsyncMock() + mock_session_prisma.return_value.find_first = find_first + + find_many = AsyncMock(return_value=[]) + mock_msg_prisma.return_value.find_many = find_many + + yield find_first, find_many + + +# ---------- Basic pagination ---------- + + +@pytest.mark.asyncio +async def test_basic_page_returns_messages_ascending( + mock_db: tuple[AsyncMock, AsyncMock], +): + """Messages are returned in ascending sequence order.""" + find_first, _ = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(3), _make_msg(2), _make_msg(1)], + ) + + page = await get_chat_messages_paginated(SESSION_ID, limit=5) + + assert isinstance(page, PaginatedMessages) + assert [m.sequence for m in page.messages] == [1, 2, 3] + assert page.has_more is False + assert page.oldest_sequence == 1 + + +@pytest.mark.asyncio +async def test_has_more_when_results_exceed_limit( + mock_db: tuple[AsyncMock, AsyncMock], +): + """has_more is True when DB returns more than limit items.""" + find_first, _ = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(3), _make_msg(2), _make_msg(1)], + ) + + page = await get_chat_messages_paginated(SESSION_ID, limit=2) + + assert page is not None + assert page.has_more is True + assert len(page.messages) == 2 + assert [m.sequence for m in page.messages] == [2, 3] + + +@pytest.mark.asyncio +async def test_empty_session_returns_no_messages( + mock_db: tuple[AsyncMock, AsyncMock], +): + find_first, _ = mock_db + find_first.return_value = _make_session(messages=[]) + + page = await get_chat_messages_paginated(SESSION_ID, limit=50) + + assert page is not None + assert page.messages == [] + assert page.has_more is False + assert page.oldest_sequence is None + + +@pytest.mark.asyncio +async def test_before_sequence_filters_correctly( + mock_db: tuple[AsyncMock, AsyncMock], +): + """before_sequence is passed as a where filter inside the Messages include.""" + find_first, _ = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(2), _make_msg(1)], + ) + + await get_chat_messages_paginated(SESSION_ID, limit=50, before_sequence=5) + + call_kwargs = find_first.call_args + include = call_kwargs.kwargs.get("include") or call_kwargs[1].get("include") + assert include["Messages"]["where"] == {"sequence": {"lt": 5}} + + +@pytest.mark.asyncio +async def test_no_where_on_messages_without_before_sequence( + mock_db: tuple[AsyncMock, AsyncMock], +): + """Without before_sequence, the Messages include has no where clause.""" + find_first, _ = mock_db + find_first.return_value = _make_session(messages=[_make_msg(1)]) + + await get_chat_messages_paginated(SESSION_ID, limit=50) + + call_kwargs = find_first.call_args + include = call_kwargs.kwargs.get("include") or call_kwargs[1].get("include") + assert "where" not in include["Messages"] + + +@pytest.mark.asyncio +async def test_user_id_filter_applied_to_session_where( + mock_db: tuple[AsyncMock, AsyncMock], +): + """user_id adds a userId filter to the session-level where clause.""" + find_first, _ = mock_db + find_first.return_value = _make_session(messages=[_make_msg(1)]) + + await get_chat_messages_paginated(SESSION_ID, limit=50, user_id="user-abc") + + call_kwargs = find_first.call_args + where = call_kwargs.kwargs.get("where") or call_kwargs[1].get("where") + assert where["userId"] == "user-abc" + + +@pytest.mark.asyncio +async def test_session_not_found_returns_none( + mock_db: tuple[AsyncMock, AsyncMock], +): + """Returns None when session doesn't exist or user doesn't own it.""" + find_first, _ = mock_db + find_first.return_value = None + + page = await get_chat_messages_paginated(SESSION_ID, limit=50) + + assert page is None + + +@pytest.mark.asyncio +async def test_session_info_included_in_result( + mock_db: tuple[AsyncMock, AsyncMock], +): + """PaginatedMessages includes session metadata.""" + find_first, _ = mock_db + find_first.return_value = _make_session(messages=[_make_msg(1)]) + + page = await get_chat_messages_paginated(SESSION_ID, limit=50) + + assert page is not None + assert page.session.session_id == SESSION_ID + + +# ---------- Backward boundary expansion ---------- + + +@pytest.mark.asyncio +async def test_boundary_expansion_includes_assistant( + mock_db: tuple[AsyncMock, AsyncMock], +): + """When page starts with a tool message, expand backward to include + the owning assistant message.""" + find_first, find_many = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(5, role="tool"), _make_msg(4, role="tool")], + ) + find_many.return_value = [_make_msg(3, role="assistant")] + + page = await get_chat_messages_paginated(SESSION_ID, limit=5) + + assert page is not None + assert [m.sequence for m in page.messages] == [3, 4, 5] + assert page.messages[0].role == "assistant" + assert page.oldest_sequence == 3 + + +@pytest.mark.asyncio +async def test_boundary_expansion_includes_multiple_tool_msgs( + mock_db: tuple[AsyncMock, AsyncMock], +): + """Boundary expansion scans past consecutive tool messages to find + the owning assistant.""" + find_first, find_many = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(7, role="tool")], + ) + find_many.return_value = [ + _make_msg(6, role="tool"), + _make_msg(5, role="tool"), + _make_msg(4, role="assistant"), + ] + + page = await get_chat_messages_paginated(SESSION_ID, limit=5) + + assert page is not None + assert [m.sequence for m in page.messages] == [4, 5, 6, 7] + assert page.messages[0].role == "assistant" + + +@pytest.mark.asyncio +async def test_boundary_expansion_sets_has_more_when_not_at_start( + mock_db: tuple[AsyncMock, AsyncMock], +): + """After boundary expansion, has_more=True if expanded msgs aren't at seq 0.""" + find_first, find_many = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(3, role="tool")], + ) + find_many.return_value = [_make_msg(2, role="assistant")] + + page = await get_chat_messages_paginated(SESSION_ID, limit=5) + + assert page is not None + assert page.has_more is True + + +@pytest.mark.asyncio +async def test_boundary_expansion_no_has_more_at_conversation_start( + mock_db: tuple[AsyncMock, AsyncMock], +): + """has_more stays False when boundary expansion reaches seq 0.""" + find_first, find_many = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(1, role="tool")], + ) + find_many.return_value = [_make_msg(0, role="assistant")] + + page = await get_chat_messages_paginated(SESSION_ID, limit=5) + + assert page is not None + assert page.has_more is False + assert page.oldest_sequence == 0 + + +@pytest.mark.asyncio +async def test_no_boundary_expansion_when_first_msg_not_tool( + mock_db: tuple[AsyncMock, AsyncMock], +): + """No boundary expansion when the first message is not a tool message.""" + find_first, find_many = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(3, role="user"), _make_msg(2, role="assistant")], + ) + + page = await get_chat_messages_paginated(SESSION_ID, limit=5) + + assert page is not None + assert find_many.call_count == 0 + assert [m.sequence for m in page.messages] == [2, 3] + + +@pytest.mark.asyncio +async def test_boundary_expansion_warns_when_no_owner_found( + mock_db: tuple[AsyncMock, AsyncMock], +): + """When boundary scan doesn't find a non-tool message, a warning is logged + and the boundary messages are still included.""" + find_first, find_many = mock_db + find_first.return_value = _make_session( + messages=[_make_msg(10, role="tool")], + ) + find_many.return_value = [_make_msg(i, role="tool") for i in range(9, -1, -1)] + + with patch("backend.copilot.db.logger") as mock_logger: + page = await get_chat_messages_paginated(SESSION_ID, limit=5) + mock_logger.warning.assert_called_once() + + assert page is not None + assert page.messages[0].role == "tool" + assert len(page.messages) > 1 + + +# ---------- Turn duration (integration tests) ---------- + + +@pytest.mark.asyncio(loop_scope="session") +async def test_set_turn_duration_updates_cache_in_place(setup_test_user, test_user_id): + """set_turn_duration patches the cached session without invalidation. + + Verifies that after calling set_turn_duration the Redis-cached session + reflects the updated durationMs on the last assistant message, without + the cache having been deleted and re-populated (which could race with + concurrent get_chat_session calls). + """ + session = ChatSession.new(user_id=test_user_id, dry_run=False) + session.messages = [ + CopilotChatMessage(role="user", content="hello"), + CopilotChatMessage(role="assistant", content="hi there"), + ] + session = await upsert_chat_session(session) + + # Ensure the session is in cache + cached = await get_chat_session(session.session_id, test_user_id) + assert cached is not None + assert cached.messages[-1].duration_ms is None + + # Update turn duration — should patch cache in-place + await set_turn_duration(session.session_id, 1234) + + # Read from cache (not DB) — the cache should already have the update + updated = await get_chat_session(session.session_id, test_user_id) + assert updated is not None + assistant_msgs = [m for m in updated.messages if m.role == "assistant"] + assert len(assistant_msgs) == 1 + assert assistant_msgs[0].duration_ms == 1234 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_set_turn_duration_no_assistant_message(setup_test_user, test_user_id): + """set_turn_duration is a no-op when there are no assistant messages.""" + session = ChatSession.new(user_id=test_user_id, dry_run=False) + session.messages = [ + CopilotChatMessage(role="user", content="hello"), + ] + session = await upsert_chat_session(session) + + # Should not raise + await set_turn_duration(session.session_id, 5678) + + cached = await get_chat_session(session.session_id, test_user_id) + assert cached is not None + # User message should not have durationMs + assert cached.messages[0].duration_ms is None diff --git a/autogpt_platform/backend/backend/copilot/executor/processor.py b/autogpt_platform/backend/backend/copilot/executor/processor.py index c111cd6df7..f94821f0e1 100644 --- a/autogpt_platform/backend/backend/copilot/executor/processor.py +++ b/autogpt_platform/backend/backend/copilot/executor/processor.py @@ -13,7 +13,7 @@ import time from backend.copilot import stream_registry from backend.copilot.baseline import stream_chat_completion_baseline -from backend.copilot.config import ChatConfig +from backend.copilot.config import ChatConfig, CopilotMode from backend.copilot.response_model import StreamError from backend.copilot.sdk import service as sdk_service from backend.copilot.sdk.dummy import stream_chat_completion_dummy @@ -30,6 +30,57 @@ from .utils import CoPilotExecutionEntry, CoPilotLogMetadata logger = TruncatedLogger(logging.getLogger(__name__), prefix="[CoPilotExecutor]") +# ============ Mode Routing ============ # + + +async def resolve_effective_mode( + mode: CopilotMode | None, + user_id: str | None, +) -> CopilotMode | None: + """Strip ``mode`` when the user is not entitled to the toggle. + + The UI gates the mode toggle behind ``CHAT_MODE_OPTION``; the + processor enforces the same gate server-side so an authenticated + user cannot bypass the flag by crafting a request directly. + """ + if mode is None: + return None + allowed = await is_feature_enabled( + Flag.CHAT_MODE_OPTION, + user_id or "anonymous", + default=False, + ) + if not allowed: + logger.info(f"Ignoring mode={mode} — CHAT_MODE_OPTION is disabled for user") + return None + return mode + + +async def resolve_use_sdk_for_mode( + mode: CopilotMode | None, + user_id: str | None, + *, + use_claude_code_subscription: bool, + config_default: bool, +) -> bool: + """Pick the SDK vs baseline path for a single turn. + + Per-request ``mode`` wins whenever it is set (after the + ``CHAT_MODE_OPTION`` gate has been applied upstream). Otherwise + falls back to the Claude Code subscription override, then the + ``COPILOT_SDK`` LaunchDarkly flag, then the config default. + """ + if mode == "fast": + return False + if mode == "extended_thinking": + return True + return use_claude_code_subscription or await is_feature_enabled( + Flag.COPILOT_SDK, + user_id or "anonymous", + default=config_default, + ) + + # ============ Module Entry Points ============ # # Thread-local storage for processor instances @@ -250,21 +301,26 @@ class CoPilotProcessor: if config.test_mode: stream_fn = stream_chat_completion_dummy log.warning("Using DUMMY service (CHAT_TEST_MODE=true)") + effective_mode = None else: - use_sdk = ( - config.use_claude_code_subscription - or await is_feature_enabled( - Flag.COPILOT_SDK, - entry.user_id or "anonymous", - default=config.use_claude_agent_sdk, - ) + # Enforce server-side feature-flag gate so unauthorised + # users cannot force a mode by crafting the request. + effective_mode = await resolve_effective_mode(entry.mode, entry.user_id) + use_sdk = await resolve_use_sdk_for_mode( + effective_mode, + entry.user_id, + use_claude_code_subscription=config.use_claude_code_subscription, + config_default=config.use_claude_agent_sdk, ) stream_fn = ( sdk_service.stream_chat_completion_sdk if use_sdk else stream_chat_completion_baseline ) - log.info(f"Using {'SDK' if use_sdk else 'baseline'} service") + log.info( + f"Using {'SDK' if use_sdk else 'baseline'} service " + f"(mode={effective_mode or 'default'})" + ) # Stream chat completion and publish chunks to Redis. # stream_and_publish wraps the raw stream with registry @@ -276,6 +332,7 @@ class CoPilotProcessor: user_id=entry.user_id, context=entry.context, file_ids=entry.file_ids, + mode=effective_mode, ) async for chunk in stream_registry.stream_and_publish( session_id=entry.session_id, diff --git a/autogpt_platform/backend/backend/copilot/executor/processor_test.py b/autogpt_platform/backend/backend/copilot/executor/processor_test.py new file mode 100644 index 0000000000..f565c5a2b3 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/executor/processor_test.py @@ -0,0 +1,175 @@ +"""Unit tests for CoPilot mode routing logic in the processor. + +Tests cover the mode→service mapping: + - 'fast' → baseline service + - 'extended_thinking' → SDK service + - None → feature flag / config fallback + +as well as the ``CHAT_MODE_OPTION`` server-side gate. The tests import +the real production helpers from ``processor.py`` so the routing logic +has meaningful coverage. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from backend.copilot.executor.processor import ( + resolve_effective_mode, + resolve_use_sdk_for_mode, +) + + +class TestResolveUseSdkForMode: + """Tests for the per-request mode routing logic.""" + + @pytest.mark.asyncio + async def test_fast_mode_uses_baseline(self): + """mode='fast' always routes to baseline, regardless of flags.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=True), + ): + assert ( + await resolve_use_sdk_for_mode( + "fast", + "user-1", + use_claude_code_subscription=True, + config_default=True, + ) + is False + ) + + @pytest.mark.asyncio + async def test_extended_thinking_uses_sdk(self): + """mode='extended_thinking' always routes to SDK, regardless of flags.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=False), + ): + assert ( + await resolve_use_sdk_for_mode( + "extended_thinking", + "user-1", + use_claude_code_subscription=False, + config_default=False, + ) + is True + ) + + @pytest.mark.asyncio + async def test_none_mode_uses_subscription_override(self): + """mode=None with claude_code_subscription=True routes to SDK.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=False), + ): + assert ( + await resolve_use_sdk_for_mode( + None, + "user-1", + use_claude_code_subscription=True, + config_default=False, + ) + is True + ) + + @pytest.mark.asyncio + async def test_none_mode_uses_feature_flag(self): + """mode=None with feature flag enabled routes to SDK.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=True), + ) as flag_mock: + assert ( + await resolve_use_sdk_for_mode( + None, + "user-1", + use_claude_code_subscription=False, + config_default=False, + ) + is True + ) + flag_mock.assert_awaited_once() + + @pytest.mark.asyncio + async def test_none_mode_uses_config_default(self): + """mode=None falls back to config.use_claude_agent_sdk.""" + # When LaunchDarkly returns the default (True), we expect SDK routing. + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=True), + ): + assert ( + await resolve_use_sdk_for_mode( + None, + "user-1", + use_claude_code_subscription=False, + config_default=True, + ) + is True + ) + + @pytest.mark.asyncio + async def test_none_mode_all_disabled(self): + """mode=None with all flags off routes to baseline.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=False), + ): + assert ( + await resolve_use_sdk_for_mode( + None, + "user-1", + use_claude_code_subscription=False, + config_default=False, + ) + is False + ) + + +class TestResolveEffectiveMode: + """Tests for the CHAT_MODE_OPTION server-side gate.""" + + @pytest.mark.asyncio + async def test_none_mode_passes_through(self): + """mode=None is returned as-is without a flag check.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=False), + ) as flag_mock: + assert await resolve_effective_mode(None, "user-1") is None + flag_mock.assert_not_awaited() + + @pytest.mark.asyncio + async def test_mode_stripped_when_flag_disabled(self): + """When CHAT_MODE_OPTION is off, mode is dropped to None.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=False), + ): + assert await resolve_effective_mode("fast", "user-1") is None + assert await resolve_effective_mode("extended_thinking", "user-1") is None + + @pytest.mark.asyncio + async def test_mode_preserved_when_flag_enabled(self): + """When CHAT_MODE_OPTION is on, the user-selected mode is preserved.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=True), + ): + assert await resolve_effective_mode("fast", "user-1") == "fast" + assert ( + await resolve_effective_mode("extended_thinking", "user-1") + == "extended_thinking" + ) + + @pytest.mark.asyncio + async def test_anonymous_user_with_mode(self): + """Anonymous users (user_id=None) still pass through the gate.""" + with patch( + "backend.copilot.executor.processor.is_feature_enabled", + new=AsyncMock(return_value=False), + ) as flag_mock: + assert await resolve_effective_mode("fast", None) is None + flag_mock.assert_awaited_once() diff --git a/autogpt_platform/backend/backend/copilot/executor/utils.py b/autogpt_platform/backend/backend/copilot/executor/utils.py index 9edd90b462..2a25c202fe 100644 --- a/autogpt_platform/backend/backend/copilot/executor/utils.py +++ b/autogpt_platform/backend/backend/copilot/executor/utils.py @@ -9,6 +9,7 @@ import logging from pydantic import BaseModel +from backend.copilot.config import CopilotMode from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig from backend.util.logging import TruncatedLogger, is_structured_logging_enabled @@ -162,6 +163,9 @@ class CoPilotExecutionEntry(BaseModel): team_id: str | None = None """Active workspace for tenant-scoped execution""" + mode: CopilotMode | None = None + """Autopilot mode override: 'fast' or 'extended_thinking'. None = server default.""" + class CancelCoPilotEvent(BaseModel): """Event to cancel a CoPilot operation.""" @@ -183,6 +187,7 @@ async def enqueue_copilot_turn( file_ids: list[str] | None = None, organization_id: str | None = None, team_id: str | None = None, + mode: CopilotMode | None = None, ) -> None: """Enqueue a CoPilot task for processing by the executor service. @@ -194,6 +199,7 @@ async def enqueue_copilot_turn( is_user_message: Whether the message is from the user (vs system/assistant) context: Optional context for the message (e.g., {url: str, content: str}) file_ids: Optional workspace file IDs attached to the user's message + mode: Autopilot mode override ('fast' or 'extended_thinking'). None = server default. """ from backend.util.clients import get_async_copilot_queue @@ -207,6 +213,7 @@ async def enqueue_copilot_turn( file_ids=file_ids, organization_id=organization_id, team_id=team_id, + mode=mode, ) queue_client = await get_async_copilot_queue() diff --git a/autogpt_platform/backend/backend/copilot/executor/utils_test.py b/autogpt_platform/backend/backend/copilot/executor/utils_test.py new file mode 100644 index 0000000000..47602551ba --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/executor/utils_test.py @@ -0,0 +1,123 @@ +"""Tests for CoPilot executor utils (queue config, message models, logging).""" + +from backend.copilot.executor.utils import ( + COPILOT_EXECUTION_EXCHANGE, + COPILOT_EXECUTION_QUEUE_NAME, + COPILOT_EXECUTION_ROUTING_KEY, + CancelCoPilotEvent, + CoPilotExecutionEntry, + CoPilotLogMetadata, + create_copilot_queue_config, +) + + +class TestCoPilotExecutionEntry: + def test_basic_fields(self): + entry = CoPilotExecutionEntry( + session_id="s1", + user_id="u1", + message="hello", + ) + assert entry.session_id == "s1" + assert entry.user_id == "u1" + assert entry.message == "hello" + assert entry.is_user_message is True + assert entry.mode is None + assert entry.context is None + assert entry.file_ids is None + + def test_mode_field(self): + entry = CoPilotExecutionEntry( + session_id="s1", + user_id="u1", + message="test", + mode="fast", + ) + assert entry.mode == "fast" + + entry2 = CoPilotExecutionEntry( + session_id="s1", + user_id="u1", + message="test", + mode="extended_thinking", + ) + assert entry2.mode == "extended_thinking" + + def test_optional_fields(self): + entry = CoPilotExecutionEntry( + session_id="s1", + user_id="u1", + message="test", + turn_id="t1", + context={"url": "https://example.com"}, + file_ids=["f1", "f2"], + is_user_message=False, + ) + assert entry.turn_id == "t1" + assert entry.context == {"url": "https://example.com"} + assert entry.file_ids == ["f1", "f2"] + assert entry.is_user_message is False + + def test_serialization_roundtrip(self): + entry = CoPilotExecutionEntry( + session_id="s1", + user_id="u1", + message="hello", + mode="fast", + ) + json_str = entry.model_dump_json() + restored = CoPilotExecutionEntry.model_validate_json(json_str) + assert restored == entry + + +class TestCancelCoPilotEvent: + def test_basic(self): + event = CancelCoPilotEvent(session_id="s1") + assert event.session_id == "s1" + + def test_serialization(self): + event = CancelCoPilotEvent(session_id="s1") + restored = CancelCoPilotEvent.model_validate_json(event.model_dump_json()) + assert restored.session_id == "s1" + + +class TestCreateCopilotQueueConfig: + def test_returns_valid_config(self): + config = create_copilot_queue_config() + assert len(config.exchanges) == 2 + assert len(config.queues) == 2 + + def test_execution_queue_properties(self): + config = create_copilot_queue_config() + exec_queue = next( + q for q in config.queues if q.name == COPILOT_EXECUTION_QUEUE_NAME + ) + assert exec_queue.durable is True + assert exec_queue.exchange == COPILOT_EXECUTION_EXCHANGE + assert exec_queue.routing_key == COPILOT_EXECUTION_ROUTING_KEY + + def test_cancel_queue_uses_fanout(self): + config = create_copilot_queue_config() + cancel_queue = next( + q for q in config.queues if q.name != COPILOT_EXECUTION_QUEUE_NAME + ) + assert cancel_queue.exchange is not None + assert cancel_queue.exchange.type.value == "fanout" + + +class TestCoPilotLogMetadata: + def test_creates_logger_with_metadata(self): + import logging + + base_logger = logging.getLogger("test") + log = CoPilotLogMetadata(base_logger, session_id="s1", user_id="u1") + assert log is not None + + def test_filters_none_values(self): + import logging + + base_logger = logging.getLogger("test") + log = CoPilotLogMetadata( + base_logger, session_id="s1", user_id=None, turn_id="t1" + ) + assert log is not None diff --git a/autogpt_platform/backend/backend/copilot/model.py b/autogpt_platform/backend/backend/copilot/model.py index 9afc380d68..9bb7964b93 100644 --- a/autogpt_platform/backend/backend/copilot/model.py +++ b/autogpt_platform/backend/backend/copilot/model.py @@ -64,6 +64,7 @@ class ChatMessage(BaseModel): refusal: str | None = None tool_calls: list[dict] | None = None function_call: dict | None = None + sequence: int | None = None duration_ms: int | None = None @staticmethod @@ -77,10 +78,54 @@ class ChatMessage(BaseModel): refusal=prisma_message.refusal, tool_calls=_parse_json_field(prisma_message.toolCalls), function_call=_parse_json_field(prisma_message.functionCall), + sequence=prisma_message.sequence, duration_ms=prisma_message.durationMs, ) +def is_message_duplicate( + messages: list[ChatMessage], + role: str, + content: str, +) -> bool: + """Check whether *content* is already present in the current pending turn. + + Only inspects trailing messages that share the given *role* (i.e. the + current turn). This ensures legitimately repeated messages across different + turns are not suppressed, while same-turn duplicates from stale cache are + still caught. + """ + for m in reversed(messages): + if m.role == role: + if m.content == content: + return True + else: + break + return False + + +def maybe_append_user_message( + session: "ChatSession", + message: str | None, + is_user_message: bool, +) -> bool: + """Append a user/assistant message to the session if not already present. + + The route handler already persists the user message before enqueueing, + so we check trailing same-role messages to avoid re-appending when the + session cache is slightly stale. + + Returns True if the message was appended, False if skipped. + """ + if not message: + return False + role = "user" if is_user_message else "assistant" + if is_message_duplicate(session.messages, role, message): + return False + session.messages.append(ChatMessage(role=role, content=message)) + return True + + class Usage(BaseModel): prompt_tokens: int completion_tokens: int diff --git a/autogpt_platform/backend/backend/copilot/model_test.py b/autogpt_platform/backend/backend/copilot/model_test.py index 6e748d9c6d..c78d63cc5a 100644 --- a/autogpt_platform/backend/backend/copilot/model_test.py +++ b/autogpt_platform/backend/backend/copilot/model_test.py @@ -17,6 +17,8 @@ from .model import ( ChatSession, Usage, get_chat_session, + is_message_duplicate, + maybe_append_user_message, upsert_chat_session, ) @@ -424,3 +426,151 @@ async def test_concurrent_saves_collision_detection(setup_test_user, test_user_i assert "Streaming message 1" in contents assert "Streaming message 2" in contents assert "Callback result" in contents + + +# --------------------------------------------------------------------------- # +# is_message_duplicate # +# --------------------------------------------------------------------------- # + + +def test_duplicate_detected_in_trailing_same_role(): + """Duplicate user message at the tail is detected.""" + msgs = [ + ChatMessage(role="user", content="hello"), + ChatMessage(role="assistant", content="hi there"), + ChatMessage(role="user", content="yes"), + ] + assert is_message_duplicate(msgs, "user", "yes") is True + + +def test_duplicate_not_detected_across_turns(): + """Same text in a previous turn (separated by assistant) is NOT a duplicate.""" + msgs = [ + ChatMessage(role="user", content="yes"), + ChatMessage(role="assistant", content="ok"), + ] + assert is_message_duplicate(msgs, "user", "yes") is False + + +def test_no_duplicate_on_empty_messages(): + """Empty message list never reports a duplicate.""" + assert is_message_duplicate([], "user", "hello") is False + + +def test_no_duplicate_when_content_differs(): + """Different content in the trailing same-role block is not a duplicate.""" + msgs = [ + ChatMessage(role="assistant", content="response"), + ChatMessage(role="user", content="first message"), + ] + assert is_message_duplicate(msgs, "user", "second message") is False + + +def test_duplicate_with_multiple_trailing_same_role(): + """Detects duplicate among multiple consecutive same-role messages.""" + msgs = [ + ChatMessage(role="assistant", content="response"), + ChatMessage(role="user", content="msg1"), + ChatMessage(role="user", content="msg2"), + ] + assert is_message_duplicate(msgs, "user", "msg1") is True + assert is_message_duplicate(msgs, "user", "msg2") is True + assert is_message_duplicate(msgs, "user", "msg3") is False + + +def test_duplicate_check_for_assistant_role(): + """Works correctly when checking assistant role too.""" + msgs = [ + ChatMessage(role="user", content="hi"), + ChatMessage(role="assistant", content="hello"), + ChatMessage(role="assistant", content="how can I help?"), + ] + assert is_message_duplicate(msgs, "assistant", "hello") is True + assert is_message_duplicate(msgs, "assistant", "new response") is False + + +def test_no_false_positive_when_content_is_none(): + """Messages with content=None in the trailing block do not match.""" + msgs = [ + ChatMessage(role="user", content=None), + ChatMessage(role="user", content="hello"), + ] + assert is_message_duplicate(msgs, "user", "hello") is True + # None-content message should not match any string + msgs2 = [ + ChatMessage(role="user", content=None), + ] + assert is_message_duplicate(msgs2, "user", "hello") is False + + +def test_all_same_role_messages(): + """When all messages share the same role, the entire list is scanned.""" + msgs = [ + ChatMessage(role="user", content="first"), + ChatMessage(role="user", content="second"), + ChatMessage(role="user", content="third"), + ] + assert is_message_duplicate(msgs, "user", "first") is True + assert is_message_duplicate(msgs, "user", "new") is False + + +# --------------------------------------------------------------------------- # +# maybe_append_user_message # +# --------------------------------------------------------------------------- # + + +def test_maybe_append_user_message_appends_new(): + """A new user message is appended and returns True.""" + session = ChatSession.new(user_id="u", dry_run=False) + session.messages = [ + ChatMessage(role="assistant", content="hello"), + ] + result = maybe_append_user_message(session, "new msg", is_user_message=True) + assert result is True + assert len(session.messages) == 2 + assert session.messages[-1].role == "user" + assert session.messages[-1].content == "new msg" + + +def test_maybe_append_user_message_skips_duplicate(): + """A duplicate user message is skipped and returns False.""" + session = ChatSession.new(user_id="u", dry_run=False) + session.messages = [ + ChatMessage(role="assistant", content="hello"), + ChatMessage(role="user", content="dup"), + ] + result = maybe_append_user_message(session, "dup", is_user_message=True) + assert result is False + assert len(session.messages) == 2 + + +def test_maybe_append_user_message_none_message(): + """None/empty message returns False without appending.""" + session = ChatSession.new(user_id="u", dry_run=False) + assert maybe_append_user_message(session, None, is_user_message=True) is False + assert maybe_append_user_message(session, "", is_user_message=True) is False + assert len(session.messages) == 0 + + +def test_maybe_append_assistant_message(): + """Works for assistant role when is_user_message=False.""" + session = ChatSession.new(user_id="u", dry_run=False) + session.messages = [ + ChatMessage(role="user", content="hi"), + ] + result = maybe_append_user_message(session, "response", is_user_message=False) + assert result is True + assert session.messages[-1].role == "assistant" + assert session.messages[-1].content == "response" + + +def test_maybe_append_assistant_skips_duplicate(): + """Duplicate assistant message is skipped.""" + session = ChatSession.new(user_id="u", dry_run=False) + session.messages = [ + ChatMessage(role="user", content="hi"), + ChatMessage(role="assistant", content="dup"), + ] + result = maybe_append_user_message(session, "dup", is_user_message=False) + assert result is False + assert len(session.messages) == 2 diff --git a/autogpt_platform/backend/backend/copilot/prompting.py b/autogpt_platform/backend/backend/copilot/prompting.py index 2c95c1721b..dd630a2e9b 100644 --- a/autogpt_platform/backend/backend/copilot/prompting.py +++ b/autogpt_platform/backend/backend/copilot/prompting.py @@ -126,6 +126,21 @@ After building the file, reference it with `@@agptfile:` in other tools: - When spawning sub-agents for research, ensure each has a distinct non-overlapping scope to avoid redundant searches. + +### Tool Discovery Priority + +When the user asks to interact with a service or API, follow this order: + +1. **find_block first** — Search platform blocks with `find_block`. The platform has hundreds of built-in blocks (Google Sheets, Docs, Calendar, Gmail, Slack, GitHub, etc.) that work without extra setup. + +2. **run_mcp_tool** — If no matching block exists, check if a hosted MCP server is available for the service. Only use known MCP server URLs from the registry. + +3. **SendAuthenticatedWebRequestBlock** — If no block or MCP server exists, use `SendAuthenticatedWebRequestBlock` with existing host-scoped credentials. Check available credentials via `connect_integration`. + +4. **Manual API call** — As a last resort, guide the user to set up credentials and use `SendAuthenticatedWebRequestBlock` with direct API calls. + +**Never skip step 1.** Built-in blocks are more reliable, tested, and user-friendly than MCP or raw API calls. + ### Sub-agent tasks - When using the Task tool, NEVER set `run_in_background` to true. All tasks must run in the foreground. diff --git a/autogpt_platform/backend/backend/copilot/rate_limit_test.py b/autogpt_platform/backend/backend/copilot/rate_limit_test.py index 6daca40175..6a4416148c 100644 --- a/autogpt_platform/backend/backend/copilot/rate_limit_test.py +++ b/autogpt_platform/backend/backend/copilot/rate_limit_test.py @@ -13,12 +13,21 @@ from .rate_limit import ( RateLimitExceeded, SubscriptionTier, UsageWindow, + _daily_key, + _daily_reset_time, + _weekly_key, + _weekly_reset_time, + acquire_reset_lock, check_rate_limit, + get_daily_reset_count, get_global_rate_limits, get_usage_status, get_user_tier, + increment_daily_reset_count, record_token_usage, + release_reset_lock, reset_daily_usage, + reset_user_usage, set_user_tier, ) @@ -1210,3 +1219,205 @@ class TestTierLimitsEnforced: assert daily == biz_daily # 20x # Should NOT raise — usage is within the BUSINESS tier allowance await check_rate_limit(_USER, daily, weekly) + + +# --------------------------------------------------------------------------- +# Private key/reset helpers +# --------------------------------------------------------------------------- + + +class TestKeyHelpers: + def test_daily_key_format(self): + now = datetime(2026, 4, 3, 12, 0, 0, tzinfo=UTC) + key = _daily_key("user-1", now=now) + assert "daily" in key + assert "user-1" in key + assert "2026-04-03" in key + + def test_daily_key_defaults_to_now(self): + key = _daily_key("user-1") + assert "daily" in key + assert "user-1" in key + + def test_weekly_key_format(self): + now = datetime(2026, 4, 3, 12, 0, 0, tzinfo=UTC) + key = _weekly_key("user-1", now=now) + assert "weekly" in key + assert "user-1" in key + assert "2026-W" in key + + def test_weekly_key_defaults_to_now(self): + key = _weekly_key("user-1") + assert "weekly" in key + + def test_daily_reset_time_is_next_midnight(self): + now = datetime(2026, 4, 3, 15, 30, 0, tzinfo=UTC) + reset = _daily_reset_time(now=now) + assert reset == datetime(2026, 4, 4, 0, 0, 0, tzinfo=UTC) + + def test_daily_reset_time_defaults_to_now(self): + reset = _daily_reset_time() + assert reset.hour == 0 + assert reset.minute == 0 + + def test_weekly_reset_time_is_next_monday(self): + # 2026-04-03 is a Friday + now = datetime(2026, 4, 3, 15, 30, 0, tzinfo=UTC) + reset = _weekly_reset_time(now=now) + assert reset.weekday() == 0 # Monday + assert reset == datetime(2026, 4, 6, 0, 0, 0, tzinfo=UTC) + + def test_weekly_reset_time_defaults_to_now(self): + reset = _weekly_reset_time() + assert reset.weekday() == 0 # Monday + + +# --------------------------------------------------------------------------- +# acquire_reset_lock / release_reset_lock +# --------------------------------------------------------------------------- + + +class TestResetLock: + @pytest.mark.asyncio + async def test_acquire_lock_success(self): + mock_redis = AsyncMock() + mock_redis.set = AsyncMock(return_value=True) + with patch( + "backend.copilot.rate_limit.get_redis_async", return_value=mock_redis + ): + result = await acquire_reset_lock("user-1") + assert result is True + + @pytest.mark.asyncio + async def test_acquire_lock_already_held(self): + mock_redis = AsyncMock() + mock_redis.set = AsyncMock(return_value=False) + with patch( + "backend.copilot.rate_limit.get_redis_async", return_value=mock_redis + ): + result = await acquire_reset_lock("user-1") + assert result is False + + @pytest.mark.asyncio + async def test_acquire_lock_redis_unavailable(self): + with patch( + "backend.copilot.rate_limit.get_redis_async", + side_effect=RedisError("down"), + ): + result = await acquire_reset_lock("user-1") + assert result is False + + @pytest.mark.asyncio + async def test_release_lock_success(self): + mock_redis = AsyncMock() + with patch( + "backend.copilot.rate_limit.get_redis_async", return_value=mock_redis + ): + await release_reset_lock("user-1") + mock_redis.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_release_lock_redis_unavailable(self): + with patch( + "backend.copilot.rate_limit.get_redis_async", + side_effect=RedisError("down"), + ): + # Should not raise + await release_reset_lock("user-1") + + +# --------------------------------------------------------------------------- +# get_daily_reset_count / increment_daily_reset_count +# --------------------------------------------------------------------------- + + +class TestDailyResetCount: + @pytest.mark.asyncio + async def test_get_count_returns_value(self): + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value="3") + with patch( + "backend.copilot.rate_limit.get_redis_async", return_value=mock_redis + ): + count = await get_daily_reset_count("user-1") + assert count == 3 + + @pytest.mark.asyncio + async def test_get_count_returns_zero_when_no_key(self): + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value=None) + with patch( + "backend.copilot.rate_limit.get_redis_async", return_value=mock_redis + ): + count = await get_daily_reset_count("user-1") + assert count == 0 + + @pytest.mark.asyncio + async def test_get_count_returns_none_when_redis_unavailable(self): + with patch( + "backend.copilot.rate_limit.get_redis_async", + side_effect=RedisError("down"), + ): + count = await get_daily_reset_count("user-1") + assert count is None + + @pytest.mark.asyncio + async def test_increment_count(self): + mock_pipe = MagicMock() + mock_pipe.incr = MagicMock() + mock_pipe.expire = MagicMock() + mock_pipe.execute = AsyncMock() + + mock_redis = AsyncMock() + mock_redis.pipeline = MagicMock(return_value=mock_pipe) + + with patch( + "backend.copilot.rate_limit.get_redis_async", return_value=mock_redis + ): + await increment_daily_reset_count("user-1") + mock_pipe.incr.assert_called_once() + mock_pipe.expire.assert_called_once() + + @pytest.mark.asyncio + async def test_increment_count_redis_unavailable(self): + with patch( + "backend.copilot.rate_limit.get_redis_async", + side_effect=RedisError("down"), + ): + # Should not raise + await increment_daily_reset_count("user-1") + + +# --------------------------------------------------------------------------- +# reset_user_usage +# --------------------------------------------------------------------------- + + +class TestResetUserUsage: + @pytest.mark.asyncio + async def test_resets_daily_key(self): + mock_redis = AsyncMock() + with patch( + "backend.copilot.rate_limit.get_redis_async", return_value=mock_redis + ): + await reset_user_usage("user-1") + mock_redis.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_resets_daily_and_weekly(self): + mock_redis = AsyncMock() + with patch( + "backend.copilot.rate_limit.get_redis_async", return_value=mock_redis + ): + await reset_user_usage("user-1", reset_weekly=True) + args = mock_redis.delete.call_args[0] + assert len(args) == 2 # both daily and weekly keys + + @pytest.mark.asyncio + async def test_raises_on_redis_failure(self): + with patch( + "backend.copilot.rate_limit.get_redis_async", + side_effect=RedisError("down"), + ): + with pytest.raises(RedisError): + await reset_user_usage("user-1") diff --git a/autogpt_platform/backend/backend/copilot/sdk/agent_generation_guide.md b/autogpt_platform/backend/backend/copilot/sdk/agent_generation_guide.md index cdb436429e..28b6f1c7dc 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/agent_generation_guide.md +++ b/autogpt_platform/backend/backend/copilot/sdk/agent_generation_guide.md @@ -53,6 +53,12 @@ Steps: or fix manually based on the error descriptions. Iterate until valid. 8. **Save**: Call `create_agent` (new) or `edit_agent` (existing) with the final `agent_json` +8. **Dry-run**: ALWAYS call `run_agent` with `dry_run=True` and + `wait_for_result=120` to verify the agent works end-to-end. +9. **Inspect & fix**: Check the dry-run output for errors. If issues are + found, call `edit_agent` to fix and dry-run again. Repeat until the + simulation passes or the problems are clearly unfixable. + See "REQUIRED: Dry-Run Verification Loop" section below for details. ### Agent JSON Structure @@ -246,19 +252,51 @@ call in a loop until the task is complete: Regular blocks work exactly like sub-agents as tools — wire each input field from `source_name: "tools"` on the Orchestrator side. -### Testing with Dry Run +### REQUIRED: Dry-Run Verification Loop (create -> dry-run -> fix) -After saving an agent, suggest a dry run to validate wiring without consuming -real API calls, credentials, or credits: +After creating or editing an agent, you MUST dry-run it before telling the +user the agent is ready. NEVER skip this step. -1. **Run**: Call `run_agent` or `run_block` with `dry_run=True` and provide - sample inputs. This executes the graph with mock outputs, verifying that - links resolve correctly and required inputs are satisfied. -2. **Check results**: Call `view_agent_output` with `show_execution_details=True` - to inspect the full node-by-node execution trace. This shows what each node - received as input and produced as output, making it easy to spot wiring issues. -3. **Iterate**: If the dry run reveals wiring issues or missing inputs, fix - the agent JSON and re-save before suggesting a real execution. +#### Step-by-step workflow + +1. **Create/Edit**: Call `create_agent` or `edit_agent` to save the agent. +2. **Dry-run**: Call `run_agent` with `dry_run=True`, `wait_for_result=120`, + and realistic sample inputs that exercise every path in the agent. This + simulates execution using an LLM for each block — no real API calls, + credentials, or credits are consumed. +3. **Inspect output**: Examine the dry-run result for problems. If + `wait_for_result` returns only a summary, call + `view_agent_output(execution_id=..., show_execution_details=True)` to + see the full node-by-node execution trace. Look for: + - **Errors / failed nodes** — a node raised an exception or returned an + error status. Common causes: wrong `source_name`/`sink_name` in links, + missing `input_default` values, or referencing a nonexistent block output. + - **Null / empty outputs** — data did not flow through a link. Verify that + `source_name` and `sink_name` match the block schemas exactly (case- + sensitive, including nested `_#_` notation). + - **Nodes that never executed** — the node was not reached. Likely a + missing or broken link from an upstream node. + - **Unexpected values** — data arrived but in the wrong type or + structure. Check type compatibility between linked ports. +4. **Fix**: If any issues are found, call `edit_agent` with the corrected + agent JSON, then go back to step 2. +5. **Repeat**: Continue the dry-run -> fix cycle until the simulation passes + or the problems are clearly unfixable. If you stop making progress, + report the remaining issues to the user and ask for guidance. + +#### Good vs bad dry-run output + +**Good output** (agent is ready): +- All nodes executed successfully (no errors in the execution trace) +- Data flows through every link with non-null, correctly-typed values +- The final `AgentOutputBlock` contains a meaningful result +- Status is `COMPLETED` + +**Bad output** (needs fixing): +- Status is `FAILED` — check the error message for the failing node +- An output node received `null` — trace back to find the broken link +- A node received data in the wrong format (e.g. string where list expected) +- Nodes downstream of a failing node were skipped entirely **Special block behaviour in dry-run mode:** - **OrchestratorBlock** and **AgentExecutorBlock** execute for real so the diff --git a/autogpt_platform/backend/backend/copilot/sdk/mcp_tool_guide.md b/autogpt_platform/backend/backend/copilot/sdk/mcp_tool_guide.md index 97c60168b8..a86aa2d12b 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/mcp_tool_guide.md +++ b/autogpt_platform/backend/backend/copilot/sdk/mcp_tool_guide.md @@ -28,13 +28,12 @@ Each result includes a `remotes` array with the exact server URL to use. ### Important: Check blocks first -Before using `run_mcp_tool`, always check if the platform already has blocks for the service -using `find_block`. The platform has hundreds of built-in blocks (Google Sheets, Google Docs, -Google Calendar, Gmail, etc.) that work without MCP setup. +Always follow the **Tool Discovery Priority** described in the tool notes: +call `find_block` before resorting to `run_mcp_tool`. Only use `run_mcp_tool` when: -- The service is in the known hosted MCP servers list above, OR -- You searched `find_block` first and found no matching blocks +- You searched `find_block` first and found no matching blocks, AND +- The service is in the known hosted MCP servers list above or found via the registry API **Never guess or construct MCP server URLs.** Only use URLs from the known servers list above or from the `remotes[].url` field in MCP registry search results. diff --git a/autogpt_platform/backend/backend/copilot/sdk/prompt_too_long_test.py b/autogpt_platform/backend/backend/copilot/sdk/prompt_too_long_test.py index 27e334e9bd..a9783c4079 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/prompt_too_long_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/prompt_too_long_test.py @@ -8,20 +8,19 @@ from uuid import uuid4 import pytest -from backend.util import json -from backend.util.prompt import CompressResult - -from .conftest import build_test_transcript as _build_transcript -from .service import _friendly_error_text, _is_prompt_too_long -from .transcript import ( +from backend.copilot.transcript import ( _flatten_assistant_content, _flatten_tool_result_content, _messages_to_transcript, _run_compression, _transcript_to_messages, - compact_transcript, - validate_transcript, ) +from backend.util import json +from backend.util.prompt import CompressResult + +from .conftest import build_test_transcript as _build_transcript +from .service import _friendly_error_text, _is_prompt_too_long +from .transcript import compact_transcript, validate_transcript # --------------------------------------------------------------------------- # _flatten_assistant_content @@ -403,7 +402,7 @@ class TestCompactTranscript: }, )() with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ): @@ -438,7 +437,7 @@ class TestCompactTranscript: }, )() with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ): @@ -462,7 +461,7 @@ class TestCompactTranscript: ] ) with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, side_effect=RuntimeError("LLM unavailable"), ): @@ -568,11 +567,11 @@ class TestRunCompressionTimeout: with ( patch( - "backend.copilot.sdk.transcript.get_openai_client", + "backend.copilot.transcript.get_openai_client", return_value="fake-client", ), patch( - "backend.copilot.sdk.transcript.compress_context", + "backend.copilot.transcript.compress_context", side_effect=_mock_compress, ), ): @@ -602,11 +601,11 @@ class TestRunCompressionTimeout: with ( patch( - "backend.copilot.sdk.transcript.get_openai_client", + "backend.copilot.transcript.get_openai_client", return_value=None, ), patch( - "backend.copilot.sdk.transcript.compress_context", + "backend.copilot.transcript.compress_context", new_callable=AsyncMock, return_value=truncation_result, ) as mock_compress, diff --git a/autogpt_platform/backend/backend/copilot/sdk/retry_scenarios_test.py b/autogpt_platform/backend/backend/copilot/sdk/retry_scenarios_test.py index 9bacffb6a8..2873ee596d 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/retry_scenarios_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/retry_scenarios_test.py @@ -26,18 +26,17 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from backend.util import json - -from .conftest import build_test_transcript as _build_transcript -from .service import _MAX_STREAM_ATTEMPTS, _reduce_context -from .transcript import ( +from backend.copilot.transcript import ( _flatten_assistant_content, _flatten_tool_result_content, _messages_to_transcript, _transcript_to_messages, - compact_transcript, - validate_transcript, ) +from backend.util import json + +from .conftest import build_test_transcript as _build_transcript +from .service import _MAX_STREAM_ATTEMPTS, _reduce_context +from .transcript import compact_transcript, validate_transcript from .transcript_builder import TranscriptBuilder # --------------------------------------------------------------------------- @@ -113,7 +112,7 @@ class TestScenarioCompactAndRetry: )(), ), patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ), @@ -170,7 +169,7 @@ class TestScenarioCompactFailsFallback: )(), ), patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, side_effect=RuntimeError("LLM unavailable"), ), @@ -261,7 +260,7 @@ class TestScenarioDoubleFailDBFallback: )(), ), patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ), @@ -337,7 +336,7 @@ class TestScenarioCompactionIdentical: )(), ), patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ), @@ -730,7 +729,7 @@ class TestRetryEdgeCases: )(), ), patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ), @@ -841,7 +840,7 @@ class TestRetryStateReset: )(), ), patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, side_effect=RuntimeError("boom"), ), @@ -1405,9 +1404,9 @@ class TestStreamChatCompletionRetryIntegration: events.append(event) # Should NOT retry — only 1 attempt for auth errors - assert attempt_count[0] == 1, ( - f"Expected 1 attempt (no retry for auth error), " f"got {attempt_count[0]}" - ) + assert ( + attempt_count[0] == 1 + ), f"Expected 1 attempt (no retry for auth error), got {attempt_count[0]}" errors = [e for e in events if isinstance(e, StreamError)] assert errors, "Expected StreamError" assert errors[0].code == "sdk_stream_error" diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index b4321d2520..8c670ea8b9 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -34,12 +34,23 @@ from pydantic import BaseModel from backend.copilot.context import get_workspace_manager from backend.copilot.permissions import apply_tool_permissions from backend.copilot.rate_limit import get_user_tier +from backend.copilot.transcript import ( + _run_compression, + cleanup_stale_project_dirs, + compact_transcript, + download_transcript, + read_compacted_entries, + upload_transcript, + validate_transcript, + write_transcript_to_tempfile, +) +from backend.copilot.transcript_builder import TranscriptBuilder from backend.data.redis_client import get_redis_async from backend.executor.cluster_lock import AsyncClusterLock from backend.util.exceptions import NotFoundError from backend.util.settings import Settings -from ..config import ChatConfig +from ..config import ChatConfig, CopilotMode from ..constants import ( COPILOT_ERROR_PREFIX, COPILOT_RETRYABLE_ERROR_PREFIX, @@ -52,6 +63,7 @@ from ..model import ( ChatMessage, ChatSession, get_chat_session, + maybe_append_user_message, update_session_title, upsert_chat_session, ) @@ -93,17 +105,6 @@ from .tool_adapter import ( set_execution_context, wait_for_stash, ) -from .transcript import ( - _run_compression, - cleanup_stale_project_dirs, - compact_transcript, - download_transcript, - read_compacted_entries, - upload_transcript, - validate_transcript, - write_transcript_to_tempfile, -) -from .transcript_builder import TranscriptBuilder logger = logging.getLogger(__name__) config = ChatConfig() @@ -130,6 +131,11 @@ _CIRCUIT_BREAKER_ERROR_MSG = ( "Try breaking your request into smaller parts." ) +# Idle timeout: abort the stream if no meaningful SDK message (only heartbeats) +# arrives for this many seconds. This catches hung tool calls (e.g. WebSearch +# hanging on a search provider that never responds). +_IDLE_TIMEOUT_SECONDS = 10 * 60 # 10 minutes + # Patterns that indicate the prompt/request exceeds the model's context limit. # Matched case-insensitively against the full exception chain. _PROMPT_TOO_LONG_PATTERNS: tuple[str, ...] = ( @@ -1272,6 +1278,8 @@ async def _run_stream_attempt( await client.query(state.query_message, session_id=ctx.session_id) state.transcript_builder.append_user(content=ctx.current_message) + _last_real_msg_time = time.monotonic() + async for sdk_msg in _iter_sdk_messages(client): # Heartbeat sentinel — refresh lock and keep SSE alive if sdk_msg is None: @@ -1279,8 +1287,34 @@ async def _run_stream_attempt( for ev in ctx.compaction.emit_start_if_ready(): yield ev yield StreamHeartbeat() + + # Idle timeout: if no real SDK message for too long, a tool + # call is likely hung (e.g. WebSearch provider not responding). + idle_seconds = time.monotonic() - _last_real_msg_time + if idle_seconds >= _IDLE_TIMEOUT_SECONDS: + logger.error( + "%s Idle timeout after %.0fs with no SDK message — " + "aborting stream (likely hung tool call)", + ctx.log_prefix, + idle_seconds, + ) + stream_error_msg = ( + "A tool call appears to be stuck " + "(no response for 10 minutes). " + "Please try again." + ) + stream_error_code = "idle_timeout" + _append_error_marker(ctx.session, stream_error_msg, retryable=True) + yield StreamError( + errorText=stream_error_msg, + code=stream_error_code, + ) + ended_with_stream_error = True + break continue + _last_real_msg_time = time.monotonic() + logger.info( "%s Received: %s %s (unresolved=%d, current=%d, resolved=%d)", ctx.log_prefix, @@ -1529,9 +1563,21 @@ async def _run_stream_attempt( # --- Intermediate persistence --- # Flush session messages to DB periodically so page reloads # show progress during long-running turns. + # + # IMPORTANT: Skip the flush while tool calls are pending + # (tool_calls set on assistant but results not yet received). + # The DB save is append-only (uses start_sequence), so if we + # flush the assistant message before tool_calls are set on it + # (text and tool_use arrive as separate SDK events), the + # tool_calls update is lost — the next flush starts past it. _msgs_since_flush += 1 now = time.monotonic() - if ( + has_pending_tools = ( + acc.has_appended_assistant + and acc.accumulated_tool_calls + and not acc.has_tool_results + ) + if not has_pending_tools and ( _msgs_since_flush >= _FLUSH_MESSAGE_THRESHOLD or (now - _last_flush_time) >= _FLUSH_INTERVAL_SECONDS ): @@ -1631,6 +1677,7 @@ async def stream_chat_completion_sdk( session: ChatSession | None = None, file_ids: list[str] | None = None, permissions: "CopilotPermissions | None" = None, + mode: CopilotMode | None = None, **_kwargs: Any, ) -> AsyncIterator[StreamBaseResponse]: """Stream chat completion using Claude Agent SDK. @@ -1639,7 +1686,10 @@ async def stream_chat_completion_sdk( file_ids: Optional workspace file IDs attached to the user's message. Images are embedded as vision content blocks; other files are saved to the SDK working directory for the Read tool. + mode: Accepted for signature compatibility with the baseline path. + The SDK path does not currently branch on this value. """ + _ = mode # SDK path ignores the requested mode. if session is None: session = await get_chat_session(session_id, user_id) @@ -1670,19 +1720,12 @@ async def stream_chat_completion_sdk( ) session.messages.pop() - # Append the new message to the session if it's not already there - new_message_role = "user" if is_user_message else "assistant" - if message and ( - len(session.messages) == 0 - or not ( - session.messages[-1].role == new_message_role - and session.messages[-1].content == message - ) - ): - session.messages.append(ChatMessage(role=new_message_role, content=message)) + if maybe_append_user_message(session, message, is_user_message): if is_user_message: track_user_message( - user_id=user_id, session_id=session_id, message_length=len(message) + user_id=user_id, + session_id=session_id, + message_length=len(message or ""), ) # Structured log prefix: [SDK][][T] diff --git a/autogpt_platform/backend/backend/copilot/sdk/thinking_blocks_test.py b/autogpt_platform/backend/backend/copilot/sdk/thinking_blocks_test.py index c734f07c89..48d38100b5 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/thinking_blocks_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/thinking_blocks_test.py @@ -27,20 +27,19 @@ from backend.copilot.response_model import ( StreamTextDelta, StreamTextStart, ) -from backend.util import json - -from .conftest import build_structured_transcript -from .response_adapter import SDKResponseAdapter -from .service import _format_sdk_content_blocks -from .transcript import ( +from backend.copilot.transcript import ( _find_last_assistant_entry, _flatten_assistant_content, _messages_to_transcript, _rechain_tail, _transcript_to_messages, - compact_transcript, - validate_transcript, ) +from backend.util import json + +from .conftest import build_structured_transcript +from .response_adapter import SDKResponseAdapter +from .service import _format_sdk_content_blocks +from .transcript import compact_transcript, validate_transcript # --------------------------------------------------------------------------- # Fixtures: realistic thinking block content @@ -439,7 +438,7 @@ class TestCompactTranscriptThinkingBlocks: }, )() with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ): @@ -498,7 +497,7 @@ class TestCompactTranscriptThinkingBlocks: )() with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", side_effect=mock_compression, ): await compact_transcript(transcript, model="test-model") @@ -551,7 +550,7 @@ class TestCompactTranscriptThinkingBlocks: }, )() with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ): @@ -601,7 +600,7 @@ class TestCompactTranscriptThinkingBlocks: }, )() with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ): @@ -638,7 +637,7 @@ class TestCompactTranscriptThinkingBlocks: }, )() with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ): @@ -699,7 +698,7 @@ class TestCompactTranscriptThinkingBlocks: }, )() with patch( - "backend.copilot.sdk.transcript._run_compression", + "backend.copilot.transcript._run_compression", new_callable=AsyncMock, return_value=mock_result, ): diff --git a/autogpt_platform/backend/backend/copilot/sdk/transcript.py b/autogpt_platform/backend/backend/copilot/sdk/transcript.py index 3aa1dddb37..a93bfbfe30 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/transcript.py +++ b/autogpt_platform/backend/backend/copilot/sdk/transcript.py @@ -1,1099 +1,48 @@ -"""JSONL transcript management for stateless multi-turn resume. +"""Re-export public API from shared ``backend.copilot.transcript``. -The Claude Code CLI persists conversations as JSONL files (one JSON object per -line). When the SDK's ``Stop`` hook fires we read this file, strip bloat -(progress entries, metadata), and upload the result to bucket storage. On the -next turn we download the transcript, write it to a temp file, and pass -``--resume`` so the CLI can reconstruct the full conversation. - -Storage is handled via ``WorkspaceStorageBackend`` (GCS in prod, local -filesystem for self-hosted) — no DB column needed. +The canonical implementation now lives at ``backend.copilot.transcript`` +so both the SDK and baseline paths can import without cross-package +dependencies. Public symbols are re-exported here so existing ``from +.transcript import ...`` statements within the ``sdk`` package continue +to work without modification. """ -from __future__ import annotations - -import asyncio -import logging -import os -import re -import shutil -import time -from dataclasses import dataclass -from pathlib import Path -from uuid import uuid4 - -from backend.util import json -from backend.util.clients import get_openai_client -from backend.util.prompt import CompressResult, compress_context -from backend.util.workspace_storage import GCSWorkspaceStorage, get_workspace_storage - -logger = logging.getLogger(__name__) - -# UUIDs are hex + hyphens; strip everything else to prevent path injection. -_SAFE_ID_RE = re.compile(r"[^0-9a-fA-F-]") - -# Entry types that can be safely removed from the transcript without breaking -# the parentUuid conversation tree that ``--resume`` relies on. -# - progress: UI progress ticks, no message content (avg 97KB for agent_progress) -# - file-history-snapshot: undo tracking metadata -# - queue-operation: internal queue bookkeeping -# - summary: session summaries -# - pr-link: PR link metadata -STRIPPABLE_TYPES = frozenset( - {"progress", "file-history-snapshot", "queue-operation", "summary", "pr-link"} +from backend.copilot.transcript import ( + COMPACT_MSG_ID_PREFIX, + ENTRY_TYPE_MESSAGE, + STOP_REASON_END_TURN, + STRIPPABLE_TYPES, + TRANSCRIPT_STORAGE_PREFIX, + TranscriptDownload, + cleanup_stale_project_dirs, + compact_transcript, + delete_transcript, + download_transcript, + read_compacted_entries, + strip_for_upload, + strip_progress_entries, + strip_stale_thinking_blocks, + upload_transcript, + validate_transcript, + write_transcript_to_tempfile, ) -# Thinking block types that can be stripped from non-last assistant entries. -# The Anthropic API only requires these in the *last* assistant message. -_THINKING_BLOCK_TYPES = frozenset({"thinking", "redacted_thinking"}) - - -@dataclass -class TranscriptDownload: - """Result of downloading a transcript with its metadata.""" - - content: str - message_count: int = 0 # session.messages length when uploaded - uploaded_at: float = 0.0 # epoch timestamp of upload - - -# Workspace storage constants — deterministic path from session_id. -TRANSCRIPT_STORAGE_PREFIX = "chat-transcripts" - - -# --------------------------------------------------------------------------- -# Progress stripping -# --------------------------------------------------------------------------- - - -def strip_progress_entries(content: str) -> str: - """Remove progress/metadata entries from a JSONL transcript. - - Removes entries whose ``type`` is in ``STRIPPABLE_TYPES`` and reparents - any remaining child entries so the ``parentUuid`` chain stays intact. - Typically reduces transcript size by ~30%. - - Entries that are not stripped or reparented are kept as their original - raw JSON line to avoid unnecessary re-serialization that changes - whitespace or key ordering. - """ - lines = content.strip().split("\n") - - # Parse entries, keeping the original line alongside the parsed dict. - parsed: list[tuple[str, dict | None]] = [] - for line in lines: - parsed.append((line, json.loads(line, fallback=None))) - - # First pass: identify stripped UUIDs and build parent map. - stripped_uuids: set[str] = set() - uuid_to_parent: dict[str, str] = {} - - for _line, entry in parsed: - if not isinstance(entry, dict): - continue - uid = entry.get("uuid", "") - parent = entry.get("parentUuid", "") - if uid: - uuid_to_parent[uid] = parent - if ( - entry.get("type", "") in STRIPPABLE_TYPES - and uid - and not entry.get("isCompactSummary") - ): - stripped_uuids.add(uid) - - # Second pass: keep non-stripped entries, reparenting where needed. - # Preserve original line when no reparenting is required. - reparented: set[str] = set() - for _line, entry in parsed: - if not isinstance(entry, dict): - continue - parent = entry.get("parentUuid", "") - original_parent = parent - # seen_parents is local per-entry (not shared across iterations) so - # it can only detect cycles within a single ancestry walk, not across - # entries. This is intentional: each entry's parent chain is - # independent, and reusing a global set would incorrectly short-circuit - # valid re-use of the same UUID as a parent in different subtrees. - seen_parents: set[str] = set() - while parent in stripped_uuids and parent not in seen_parents: - seen_parents.add(parent) - parent = uuid_to_parent.get(parent, "") - if parent != original_parent: - entry["parentUuid"] = parent - uid = entry.get("uuid", "") - if uid: - reparented.add(uid) - - result_lines: list[str] = [] - for line, entry in parsed: - if not isinstance(entry, dict): - result_lines.append(line) - continue - if entry.get("type", "") in STRIPPABLE_TYPES and not entry.get( - "isCompactSummary" - ): - continue - uid = entry.get("uuid", "") - if uid in reparented: - # Re-serialize only entries whose parentUuid was changed. - result_lines.append(json.dumps(entry, separators=(",", ":"))) - else: - result_lines.append(line) - - return "\n".join(result_lines) + "\n" - - -# --------------------------------------------------------------------------- -# Local file I/O (write temp file for --resume) -# --------------------------------------------------------------------------- - - -def _sanitize_id(raw_id: str, max_len: int = 36) -> str: - """Sanitize an ID for safe use in file paths. - - Session/user IDs are expected to be UUIDs (hex + hyphens). Strip - everything else and truncate to *max_len* so the result cannot introduce - path separators or other special characters. - """ - cleaned = _SAFE_ID_RE.sub("", raw_id or "")[:max_len] - return cleaned or "unknown" - - -_SAFE_CWD_PREFIX = os.path.realpath("/tmp/copilot-") - - -def _projects_base() -> str: - """Return the resolved path to the CLI's projects directory.""" - config_dir = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") - return os.path.realpath(os.path.join(config_dir, "projects")) - - -_STALE_PROJECT_DIR_SECONDS = 12 * 3600 # 12 hours — matches max session lifetime -_MAX_PROJECT_DIRS_TO_SWEEP = 50 # limit per sweep to avoid long pauses - - -def cleanup_stale_project_dirs(encoded_cwd: str | None = None) -> int: - """Remove CLI project directories older than ``_STALE_PROJECT_DIR_SECONDS``. - - Each CoPilot SDK turn creates a unique ``~/.claude/projects//`` - directory. These are intentionally kept across turns so the model can read - tool-result files via ``--resume``. However, after a session ends they - become stale. This function sweeps old ones to prevent unbounded disk - growth. - - When *encoded_cwd* is provided the sweep is scoped to that single - directory, making the operation safe in multi-tenant environments where - multiple copilot sessions share the same host. Without it the function - falls back to sweeping all directories matching the copilot naming pattern - (``-tmp-copilot-``), which is only safe for single-tenant deployments. - - Returns the number of directories removed. - """ - projects_base = _projects_base() - if not os.path.isdir(projects_base): - return 0 - - now = time.time() - removed = 0 - - # Scoped mode: only clean up the one directory for the current session. - if encoded_cwd: - target = Path(projects_base) / encoded_cwd - if not target.is_dir(): - return 0 - # Guard: only sweep copilot-generated dirs. - if "-tmp-copilot-" not in target.name: - logger.warning( - "[Transcript] Refusing to sweep non-copilot dir: %s", target.name - ) - return 0 - try: - # st_mtime is used as a proxy for session activity. Claude CLI writes - # its JSONL transcript into this directory during each turn, so mtime - # advances on every turn. A directory whose mtime is older than - # _STALE_PROJECT_DIR_SECONDS has not had an active turn in that window - # and is safe to remove (the session cannot --resume after cleanup). - age = now - target.stat().st_mtime - except OSError: - return 0 - if age < _STALE_PROJECT_DIR_SECONDS: - return 0 - try: - shutil.rmtree(target, ignore_errors=True) - removed = 1 - except OSError: - pass - if removed: - logger.info( - "[Transcript] Swept stale CLI project dir %s (age %ds > %ds)", - target.name, - int(age), - _STALE_PROJECT_DIR_SECONDS, - ) - return removed - - # Unscoped fallback: sweep all copilot dirs across the projects base. - # Only safe for single-tenant deployments; callers should prefer the - # scoped variant by passing encoded_cwd. - try: - entries = Path(projects_base).iterdir() - except OSError as e: - logger.warning("[Transcript] Failed to list projects dir: %s", e) - return 0 - - for entry in entries: - if removed >= _MAX_PROJECT_DIRS_TO_SWEEP: - break - # Only sweep copilot-generated dirs (pattern: -tmp-copilot- or - # -private-tmp-copilot-). - if "-tmp-copilot-" not in entry.name: - continue - if not entry.is_dir(): - continue - try: - # See the scoped-mode comment above: st_mtime advances on every turn, - # so a stale mtime reliably indicates an inactive session. - age = now - entry.stat().st_mtime - except OSError: - continue - if age < _STALE_PROJECT_DIR_SECONDS: - continue - - try: - shutil.rmtree(entry, ignore_errors=True) - removed += 1 - except OSError: - pass - - if removed: - logger.info( - "[Transcript] Swept %d stale CLI project dirs (older than %ds)", - removed, - _STALE_PROJECT_DIR_SECONDS, - ) - return removed - - -def read_compacted_entries(transcript_path: str) -> list[dict] | None: - """Read compacted entries from the CLI session file after compaction. - - Parses the JSONL file line-by-line, finds the ``isCompactSummary: true`` - entry, and returns it plus all entries after it. - - The CLI writes the compaction summary BEFORE sending the next message, - so the file is guaranteed to be flushed by the time we read it. - - Returns a list of parsed dicts, or ``None`` if the file cannot be read - or no compaction summary is found. - """ - if not transcript_path: - return None - - projects_base = _projects_base() - real_path = os.path.realpath(transcript_path) - if not real_path.startswith(projects_base + os.sep): - logger.warning( - "[Transcript] transcript_path outside projects base: %s", transcript_path - ) - return None - - try: - content = Path(real_path).read_text() - except OSError as e: - logger.warning( - "[Transcript] Failed to read session file %s: %s", transcript_path, e - ) - return None - - lines = content.strip().split("\n") - compact_idx: int | None = None - - for idx, line in enumerate(lines): - if not line.strip(): - continue - entry = json.loads(line, fallback=None) - if not isinstance(entry, dict): - continue - if entry.get("isCompactSummary"): - compact_idx = idx # don't break — find the LAST summary - - if compact_idx is None: - logger.debug("[Transcript] No compaction summary found in %s", transcript_path) - return None - - entries: list[dict] = [] - for line in lines[compact_idx:]: - if not line.strip(): - continue - entry = json.loads(line, fallback=None) - if isinstance(entry, dict): - entries.append(entry) - - logger.info( - "[Transcript] Read %d compacted entries from %s (summary at line %d)", - len(entries), - transcript_path, - compact_idx + 1, - ) - return entries - - -def write_transcript_to_tempfile( - transcript_content: str, - session_id: str, - cwd: str, -) -> str | None: - """Write JSONL transcript to a temp file inside *cwd* for ``--resume``. - - The file lives in the session working directory so it is cleaned up - automatically when the session ends. - - Returns the absolute path to the file, or ``None`` on failure. - """ - # Validate cwd is under the expected sandbox prefix (CodeQL sanitizer). - real_cwd = os.path.realpath(cwd) - if not real_cwd.startswith(_SAFE_CWD_PREFIX): - logger.warning("[Transcript] cwd outside sandbox: %s", cwd) - return None - - try: - os.makedirs(real_cwd, exist_ok=True) - safe_id = _sanitize_id(session_id, max_len=8) - jsonl_path = os.path.realpath( - os.path.join(real_cwd, f"transcript-{safe_id}.jsonl") - ) - if not jsonl_path.startswith(real_cwd): - logger.warning("[Transcript] Path escaped cwd: %s", jsonl_path) - return None - - with open(jsonl_path, "w") as f: - f.write(transcript_content) - - logger.info("[Transcript] Wrote resume file: %s", jsonl_path) - return jsonl_path - - except OSError as e: - logger.warning("[Transcript] Failed to write resume file: %s", e) - return None - - -def validate_transcript(content: str | None) -> bool: - """Check that a transcript has actual conversation messages. - - A valid transcript needs at least one assistant message (not just - queue-operation / file-history-snapshot metadata). We do NOT require - a ``type: "user"`` entry because with ``--resume`` the user's message - is passed as a CLI query parameter and does not appear in the - transcript file. - """ - if not content or not content.strip(): - return False - - lines = content.strip().split("\n") - - has_assistant = False - - for line in lines: - if not line.strip(): - continue - entry = json.loads(line, fallback=None) - if not isinstance(entry, dict): - return False - if entry.get("type") == "assistant": - has_assistant = True - - return has_assistant - - -# --------------------------------------------------------------------------- -# Bucket storage (GCS / local via WorkspaceStorageBackend) -# --------------------------------------------------------------------------- - - -def _storage_path_parts(user_id: str, session_id: str) -> tuple[str, str, str]: - """Return (workspace_id, file_id, filename) for a session's transcript. - - Path structure: ``chat-transcripts/{user_id}/{session_id}.jsonl`` - IDs are sanitized to hex+hyphen to prevent path traversal. - """ - return ( - TRANSCRIPT_STORAGE_PREFIX, - _sanitize_id(user_id), - f"{_sanitize_id(session_id)}.jsonl", - ) - - -def _meta_storage_path_parts(user_id: str, session_id: str) -> tuple[str, str, str]: - """Return (workspace_id, file_id, filename) for a session's transcript metadata.""" - return ( - TRANSCRIPT_STORAGE_PREFIX, - _sanitize_id(user_id), - f"{_sanitize_id(session_id)}.meta.json", - ) - - -def _build_path_from_parts(parts: tuple[str, str, str], backend: object) -> str: - """Build a full storage path from (workspace_id, file_id, filename) parts.""" - wid, fid, fname = parts - if isinstance(backend, GCSWorkspaceStorage): - blob = f"workspaces/{wid}/{fid}/{fname}" - return f"gcs://{backend.bucket_name}/{blob}" - return f"local://{wid}/{fid}/{fname}" - - -def _build_storage_path(user_id: str, session_id: str, backend: object) -> str: - """Build the full storage path string that ``retrieve()`` expects.""" - return _build_path_from_parts(_storage_path_parts(user_id, session_id), backend) - - -def _build_meta_storage_path(user_id: str, session_id: str, backend: object) -> str: - """Build the full storage path for the companion .meta.json file.""" - return _build_path_from_parts( - _meta_storage_path_parts(user_id, session_id), backend - ) - - -def strip_stale_thinking_blocks(content: str) -> str: - """Remove thinking/redacted_thinking blocks from non-last assistant entries. - - The Anthropic API only requires thinking blocks in the **last** assistant - message to be value-identical to the original response. Older assistant - entries carry stale thinking blocks that consume significant tokens - (often 10-50K each) without providing useful context for ``--resume``. - - Stripping them before upload prevents the CLI from triggering compaction - every turn just to compress away the stale thinking bloat. - """ - lines = content.strip().split("\n") - if not lines: - return content - - parsed: list[tuple[str, dict | None]] = [] - for line in lines: - parsed.append((line, json.loads(line, fallback=None))) - - # Reverse scan to find the last assistant message ID and index. - last_asst_msg_id: str | None = None - last_asst_idx: int | None = None - for i in range(len(parsed) - 1, -1, -1): - _line, entry = parsed[i] - if not isinstance(entry, dict): - continue - msg = entry.get("message", {}) - if msg.get("role") == "assistant": - last_asst_msg_id = msg.get("id") - last_asst_idx = i - break - - if last_asst_idx is None: - return content - - result_lines: list[str] = [] - stripped_count = 0 - for i, (line, entry) in enumerate(parsed): - if not isinstance(entry, dict): - result_lines.append(line) - continue - - msg = entry.get("message", {}) - # Only strip from assistant entries that are NOT the last turn. - # Use msg_id matching when available; fall back to index for entries - # without an id field. - is_last_turn = ( - last_asst_msg_id is not None and msg.get("id") == last_asst_msg_id - ) or (last_asst_msg_id is None and i == last_asst_idx) - if ( - msg.get("role") == "assistant" - and not is_last_turn - and isinstance(msg.get("content"), list) - ): - content_blocks = msg["content"] - filtered = [ - b - for b in content_blocks - if not (isinstance(b, dict) and b.get("type") in _THINKING_BLOCK_TYPES) - ] - if len(filtered) < len(content_blocks): - stripped_count += len(content_blocks) - len(filtered) - entry = {**entry, "message": {**msg, "content": filtered}} - result_lines.append(json.dumps(entry, separators=(",", ":"))) - continue - - result_lines.append(line) - - if stripped_count: - logger.info( - "[Transcript] Stripped %d stale thinking block(s) from non-last entries", - stripped_count, - ) - - return "\n".join(result_lines) + "\n" - - -async def upload_transcript( - user_id: str, - session_id: str, - content: str, - message_count: int = 0, - log_prefix: str = "[Transcript]", -) -> None: - """Strip progress entries and upload complete transcript. - - The transcript represents the FULL active context (atomic). - Each upload REPLACES the previous transcript entirely. - - The executor holds a cluster lock per session, so concurrent uploads for - the same session cannot happen. - - Args: - content: Complete JSONL transcript (from TranscriptBuilder). - message_count: ``len(session.messages)`` at upload time. - """ - # Strip metadata entries (progress, file-history-snapshot, etc.) - # Note: SDK-built transcripts shouldn't have these, but strip for safety - stripped = strip_progress_entries(content) - # Strip stale thinking blocks from older assistant entries — these consume - # significant tokens and trigger unnecessary CLI compaction every turn. - stripped = strip_stale_thinking_blocks(stripped) - if not validate_transcript(stripped): - # Log entry types for debugging — helps identify why validation failed - entry_types = [ - json.loads(line, fallback={"type": "INVALID_JSON"}).get("type", "?") - for line in stripped.strip().split("\n") - ] - logger.warning( - "%s Skipping upload — stripped content not valid " - "(types=%s, stripped_len=%d, raw_len=%d)", - log_prefix, - entry_types, - len(stripped), - len(content), - ) - logger.debug("%s Raw content preview: %s", log_prefix, content[:500]) - logger.debug("%s Stripped content: %s", log_prefix, stripped[:500]) - return - - storage = await get_workspace_storage() - wid, fid, fname = _storage_path_parts(user_id, session_id) - encoded = stripped.encode("utf-8") - - await storage.store( - workspace_id=wid, - file_id=fid, - filename=fname, - content=encoded, - ) - - # Update metadata so message_count stays current. The gap-fill logic - # in _build_query_message relies on it to avoid re-compressing messages. - try: - meta = {"message_count": message_count, "uploaded_at": time.time()} - mwid, mfid, mfname = _meta_storage_path_parts(user_id, session_id) - await storage.store( - workspace_id=mwid, - file_id=mfid, - filename=mfname, - content=json.dumps(meta).encode("utf-8"), - ) - except Exception as e: - logger.warning("%s Failed to write metadata: %s", log_prefix, e) - - logger.info( - "%s Uploaded %dB (stripped from %dB, msg_count=%d)", - log_prefix, - len(encoded), - len(content), - message_count, - ) - - -async def download_transcript( - user_id: str, - session_id: str, - log_prefix: str = "[Transcript]", -) -> TranscriptDownload | None: - """Download transcript and metadata from bucket storage. - - Returns a ``TranscriptDownload`` with the JSONL content and the - ``message_count`` watermark from the upload, or ``None`` if not found. - """ - storage = await get_workspace_storage() - path = _build_storage_path(user_id, session_id, storage) - - try: - data = await storage.retrieve(path) - content = data.decode("utf-8") - except FileNotFoundError: - logger.debug("%s No transcript in storage", log_prefix) - return None - except Exception as e: - logger.warning("%s Failed to download transcript: %s", log_prefix, e) - return None - - # Try to load metadata (best-effort — old transcripts won't have it) - message_count = 0 - uploaded_at = 0.0 - try: - meta_path = _build_meta_storage_path(user_id, session_id, storage) - meta_data = await storage.retrieve(meta_path) - meta = json.loads(meta_data.decode("utf-8"), fallback={}) - message_count = meta.get("message_count", 0) - uploaded_at = meta.get("uploaded_at", 0.0) - except FileNotFoundError: - pass # No metadata — treat as unknown (msg_count=0 → always fill gap) - except Exception as e: - logger.debug("%s Failed to load transcript metadata: %s", log_prefix, e) - - logger.info( - "%s Downloaded %dB (msg_count=%d)", log_prefix, len(content), message_count - ) - return TranscriptDownload( - content=content, - message_count=message_count, - uploaded_at=uploaded_at, - ) - - -async def delete_transcript(user_id: str, session_id: str) -> None: - """Delete transcript and its metadata from bucket storage. - - Removes both the ``.jsonl`` transcript and the companion ``.meta.json`` - so stale ``message_count`` watermarks cannot corrupt gap-fill logic. - """ - storage = await get_workspace_storage() - path = _build_storage_path(user_id, session_id, storage) - - try: - await storage.delete(path) - logger.info("[Transcript] Deleted transcript for session %s", session_id) - except Exception as e: - logger.warning("[Transcript] Failed to delete transcript: %s", e) - - # Also delete the companion .meta.json to avoid orphaned metadata. - try: - meta_path = _build_meta_storage_path(user_id, session_id, storage) - await storage.delete(meta_path) - logger.info("[Transcript] Deleted metadata for session %s", session_id) - except Exception as e: - logger.warning("[Transcript] Failed to delete metadata: %s", e) - - -# --------------------------------------------------------------------------- -# Transcript compaction — LLM summarization for prompt-too-long recovery -# --------------------------------------------------------------------------- - -# JSONL protocol values used in transcript serialization. -STOP_REASON_END_TURN = "end_turn" -COMPACT_MSG_ID_PREFIX = "msg_compact_" -ENTRY_TYPE_MESSAGE = "message" - - -def _flatten_assistant_content(blocks: list) -> str: - """Flatten assistant content blocks into a single plain-text string. - - Structured ``tool_use`` blocks are converted to ``[tool_use: name]`` - placeholders. ``thinking`` and ``redacted_thinking`` blocks are - silently dropped — they carry no useful context for compression - summaries and must not leak into compacted transcripts (the Anthropic - API requires thinking blocks in the last assistant message to be - value-identical to the original response; including stale thinking - text would violate that constraint). - - This is intentional: ``compress_context`` requires plain text for - token counting and LLM summarization. The structural loss is - acceptable because compaction only runs when the original transcript - was already too large for the model. - """ - parts: list[str] = [] - for block in blocks: - if isinstance(block, dict): - btype = block.get("type", "") - if btype in _THINKING_BLOCK_TYPES: - continue - if btype == "text": - parts.append(block.get("text", "")) - elif btype == "tool_use": - # Drop tool_use entirely — any text representation gets - # mimicked by the model as plain text instead of actual - # structured tool calls. The tool results (in the - # following user/tool_result entry) provide sufficient - # context about what happened. - continue - else: - continue - elif isinstance(block, str): - parts.append(block) - return "\n".join(parts) if parts else "" - - -def _flatten_tool_result_content(blocks: list) -> str: - """Flatten tool_result and other content blocks into plain text. - - Handles nested tool_result structures, text blocks, and raw strings. - Uses ``json.dumps`` as fallback for dict blocks without a ``text`` key - or where ``text`` is ``None``. - - Like ``_flatten_assistant_content``, structured blocks (images, nested - tool results) are reduced to text representations for compression. - """ - str_parts: list[str] = [] - for block in blocks: - if isinstance(block, dict) and block.get("type") == "tool_result": - inner = block.get("content") or "" - if isinstance(inner, list): - for sub in inner: - if isinstance(sub, dict): - sub_type = sub.get("type") - if sub_type in ("image", "document"): - # Avoid serializing base64 binary data into - # the compaction input — use a placeholder. - str_parts.append(f"[__{sub_type}__]") - elif sub_type == "text" or sub.get("text") is not None: - str_parts.append(str(sub.get("text", ""))) - else: - str_parts.append(json.dumps(sub)) - else: - str_parts.append(str(sub)) - else: - str_parts.append(str(inner)) - elif isinstance(block, dict) and block.get("type") == "text": - str_parts.append(str(block.get("text", ""))) - elif isinstance(block, dict): - # Preserve non-text/non-tool_result blocks (e.g. image) as placeholders. - # Use __prefix__ to distinguish from literal user text. - btype = block.get("type", "unknown") - str_parts.append(f"[__{btype}__]") - elif isinstance(block, str): - str_parts.append(block) - return "\n".join(str_parts) if str_parts else "" - - -def _transcript_to_messages(content: str) -> list[dict]: - """Convert JSONL transcript entries to plain message dicts for compression. - - Parses each line of the JSONL *content*, skips strippable metadata entries - (progress, file-history-snapshot, etc.), and extracts the ``role`` and - flattened ``content`` from the ``message`` field of each remaining entry. - - Structured content blocks (``tool_use``, ``tool_result``, images) are - flattened to plain text via ``_flatten_assistant_content`` and - ``_flatten_tool_result_content`` so that ``compress_context`` can - perform token counting and LLM summarization on uniform strings. - - Returns: - A list of ``{"role": str, "content": str}`` dicts suitable for - ``compress_context``. - """ - messages: list[dict] = [] - for line in content.strip().split("\n"): - if not line.strip(): - continue - entry = json.loads(line, fallback=None) - if not isinstance(entry, dict): - continue - if entry.get("type", "") in STRIPPABLE_TYPES and not entry.get( - "isCompactSummary" - ): - continue - msg = entry.get("message", {}) - role = msg.get("role", "") - if not role: - continue - msg_dict: dict = {"role": role} - raw_content = msg.get("content") - if role == "assistant" and isinstance(raw_content, list): - msg_dict["content"] = _flatten_assistant_content(raw_content) - elif isinstance(raw_content, list): - msg_dict["content"] = _flatten_tool_result_content(raw_content) - else: - msg_dict["content"] = raw_content or "" - messages.append(msg_dict) - return messages - - -def _messages_to_transcript(messages: list[dict]) -> str: - """Convert compressed message dicts back to JSONL transcript format. - - Rebuilds a minimal JSONL transcript from the ``{"role", "content"}`` - dicts returned by ``compress_context``. Each message becomes one JSONL - line with a fresh ``uuid`` / ``parentUuid`` chain so the CLI's - ``--resume`` flag can reconstruct a valid conversation tree. - - Assistant messages are wrapped in the full ``message`` envelope - (``id``, ``model``, ``stop_reason``, structured ``content`` blocks) - that the CLI expects. User messages use the simpler ``{role, content}`` - form. - - Returns: - A newline-terminated JSONL string, or an empty string if *messages* - is empty. - """ - lines: list[str] = [] - last_uuid: str = "" # root entry uses empty string, not null - for msg in messages: - role = msg.get("role", "user") - entry_type = "assistant" if role == "assistant" else "user" - uid = str(uuid4()) - content = msg.get("content", "") - if role == "assistant": - message: dict = { - "role": "assistant", - "model": "", - "id": f"{COMPACT_MSG_ID_PREFIX}{uuid4().hex[:24]}", - "type": ENTRY_TYPE_MESSAGE, - "content": [{"type": "text", "text": content}] if content else [], - "stop_reason": STOP_REASON_END_TURN, - "stop_sequence": None, - } - else: - message = {"role": role, "content": content} - entry = { - "type": entry_type, - "uuid": uid, - "parentUuid": last_uuid, - "message": message, - } - lines.append(json.dumps(entry, separators=(",", ":"))) - last_uuid = uid - return "\n".join(lines) + "\n" if lines else "" - - -_COMPACTION_TIMEOUT_SECONDS = 60 -_TRUNCATION_TIMEOUT_SECONDS = 30 - - -async def _run_compression( - messages: list[dict], - model: str, - log_prefix: str, -) -> CompressResult: - """Run LLM-based compression with truncation fallback. - - Uses the shared OpenAI client from ``get_openai_client()``. - If no client is configured or the LLM call fails, falls back to - truncation-based compression which drops older messages without - summarization. - - A 60-second timeout prevents a hung LLM call from blocking the - retry path indefinitely. The truncation fallback also has a - 30-second timeout to guard against slow tokenization on very large - transcripts. - """ - client = get_openai_client() - if client is None: - logger.warning("%s No OpenAI client configured, using truncation", log_prefix) - return await asyncio.wait_for( - compress_context(messages=messages, model=model, client=None), - timeout=_TRUNCATION_TIMEOUT_SECONDS, - ) - try: - return await asyncio.wait_for( - compress_context(messages=messages, model=model, client=client), - timeout=_COMPACTION_TIMEOUT_SECONDS, - ) - except Exception as e: - logger.warning("%s LLM compaction failed, using truncation: %s", log_prefix, e) - return await asyncio.wait_for( - compress_context(messages=messages, model=model, client=None), - timeout=_TRUNCATION_TIMEOUT_SECONDS, - ) - - -def _find_last_assistant_entry( - content: str, -) -> tuple[list[str], list[str]]: - """Split JSONL lines into (compressible_prefix, preserved_tail). - - The tail starts at the **first** entry of the last assistant turn and - includes everything after it (typically trailing user messages). An - assistant turn can span multiple consecutive JSONL entries sharing the - same ``message.id`` (e.g., a thinking entry followed by a tool_use - entry). All entries of the turn are preserved verbatim. - - The Anthropic API requires that ``thinking`` and ``redacted_thinking`` - blocks in the **last** assistant message remain value-identical to the - original response (the API validates parsed signature values, not raw - JSON bytes). By excluding the entire turn from compression we - guarantee those blocks are never altered. - - Returns ``(all_lines, [])`` when no assistant entry is found. - """ - lines = [ln for ln in content.strip().split("\n") if ln.strip()] - - # Parse all lines once to avoid double JSON deserialization. - # json.loads with fallback=None returns Any; non-dict entries are - # safely skipped by the isinstance(entry, dict) guards below. - parsed: list = [json.loads(ln, fallback=None) for ln in lines] - - # Reverse scan: find the message.id and index of the last assistant entry. - last_asst_msg_id: str | None = None - last_asst_idx: int | None = None - for i in range(len(parsed) - 1, -1, -1): - entry = parsed[i] - if not isinstance(entry, dict): - continue - msg = entry.get("message", {}) - if msg.get("role") == "assistant": - last_asst_idx = i - last_asst_msg_id = msg.get("id") - break - - if last_asst_idx is None: - return lines, [] - - # If the assistant entry has no message.id, fall back to preserving - # from that single entry onward — safer than compressing everything. - if last_asst_msg_id is None: - return lines[:last_asst_idx], lines[last_asst_idx:] - - # Forward scan: find the first entry of this turn (same message.id). - first_turn_idx: int | None = None - for i, entry in enumerate(parsed): - if not isinstance(entry, dict): - continue - msg = entry.get("message", {}) - if msg.get("role") == "assistant" and msg.get("id") == last_asst_msg_id: - first_turn_idx = i - break - - if first_turn_idx is None: - return lines, [] - return lines[:first_turn_idx], lines[first_turn_idx:] - - -async def compact_transcript( - content: str, - *, - model: str, - log_prefix: str = "[Transcript]", -) -> str | None: - """Compact an oversized JSONL transcript using LLM summarization. - - Converts transcript entries to plain messages, runs ``compress_context`` - (the same compressor used for pre-query history), and rebuilds JSONL. - - The **last assistant entry** (and any entries after it) are preserved - verbatim — never flattened or compressed. The Anthropic API requires - ``thinking`` and ``redacted_thinking`` blocks in the latest assistant - message to be value-identical to the original response (the API - validates parsed signature values, not raw JSON bytes); compressing - them would destroy the cryptographic signatures and cause - ``invalid_request_error``. - - Structured content in *older* assistant entries (``tool_use`` blocks, - ``thinking`` blocks, ``tool_result`` nesting, images) is flattened to - plain text for compression. This matches the fidelity of the Plan C - (DB compression) fallback path. - - Returns the compacted JSONL string, or ``None`` on failure. - - See also: - ``_compress_messages`` in ``service.py`` — compresses ``ChatMessage`` - lists for pre-query DB history. - """ - prefix_lines, tail_lines = _find_last_assistant_entry(content) - - # Build the JSONL string for the compressible prefix - prefix_content = "\n".join(prefix_lines) + "\n" if prefix_lines else "" - messages = _transcript_to_messages(prefix_content) if prefix_content else [] - - if len(messages) + len(tail_lines) < 2: - total = len(messages) + len(tail_lines) - logger.warning("%s Too few messages to compact (%d)", log_prefix, total) - return None - if not messages: - logger.warning("%s Nothing to compress (only tail entries remain)", log_prefix) - return None - try: - result = await _run_compression(messages, model, log_prefix) - if not result.was_compacted: - logger.warning( - "%s Compressor reports within budget but SDK rejected — " - "signalling failure", - log_prefix, - ) - return None - if not result.messages: - logger.warning("%s Compressor returned empty messages", log_prefix) - return None - logger.info( - "%s Compacted transcript: %d->%d tokens (%d summarized, %d dropped)", - log_prefix, - result.original_token_count, - result.token_count, - result.messages_summarized, - result.messages_dropped, - ) - compressed_part = _messages_to_transcript(result.messages) - - # Re-append the preserved tail (last assistant + trailing entries) - # with parentUuid patched to chain onto the compressed prefix. - tail_part = _rechain_tail(compressed_part, tail_lines) - compacted = compressed_part + tail_part - - if len(compacted) >= len(content): - # Byte count can increase due to preserved tail entries - # (thinking blocks, JSON overhead) even when token count - # decreased. Log a warning but still return — the API - # validates tokens not bytes, and the caller falls through - # to DB fallback if the transcript is still too large. - logger.warning( - "%s Compacted transcript (%d bytes) is not smaller than " - "original (%d bytes) — may still reduce token count", - log_prefix, - len(compacted), - len(content), - ) - # Authoritative validation — the caller (_reduce_context) also - # validates, but this is the canonical check that guarantees we - # never return a malformed transcript from this function. - if not validate_transcript(compacted): - logger.warning("%s Compacted transcript failed validation", log_prefix) - return None - return compacted - except Exception as e: - logger.error( - "%s Transcript compaction failed: %s", log_prefix, e, exc_info=True - ) - return None - - -def _rechain_tail(compressed_prefix: str, tail_lines: list[str]) -> str: - """Patch tail entries so their parentUuid chain links to the compressed prefix. - - The first tail entry's ``parentUuid`` is set to the ``uuid`` of the - last entry in the compressed prefix. Subsequent tail entries are - rechained to point to their predecessor in the tail — their original - ``parentUuid`` values may reference entries that were compressed away. - """ - if not tail_lines: - return "" - # Find the last uuid in the compressed prefix - last_prefix_uuid = "" - for line in reversed(compressed_prefix.strip().split("\n")): - if not line.strip(): - continue - entry = json.loads(line, fallback=None) - if isinstance(entry, dict) and "uuid" in entry: - last_prefix_uuid = entry["uuid"] - break - - result_lines: list[str] = [] - prev_uuid: str | None = None - for i, line in enumerate(tail_lines): - entry = json.loads(line, fallback=None) - if not isinstance(entry, dict): - # Safety guard: _find_last_assistant_entry already filters empty - # lines, and well-formed JSONL always parses to dicts. Non-dict - # lines are passed through unchanged; prev_uuid is intentionally - # NOT updated so the next dict entry chains to the last known uuid. - result_lines.append(line) - continue - if i == 0: - entry["parentUuid"] = last_prefix_uuid - elif prev_uuid is not None: - entry["parentUuid"] = prev_uuid - prev_uuid = entry.get("uuid") - result_lines.append(json.dumps(entry, separators=(",", ":"))) - return "\n".join(result_lines) + "\n" +__all__ = [ + "COMPACT_MSG_ID_PREFIX", + "ENTRY_TYPE_MESSAGE", + "STOP_REASON_END_TURN", + "STRIPPABLE_TYPES", + "TRANSCRIPT_STORAGE_PREFIX", + "TranscriptDownload", + "cleanup_stale_project_dirs", + "compact_transcript", + "delete_transcript", + "download_transcript", + "read_compacted_entries", + "strip_for_upload", + "strip_progress_entries", + "strip_stale_thinking_blocks", + "upload_transcript", + "validate_transcript", + "write_transcript_to_tempfile", +] diff --git a/autogpt_platform/backend/backend/copilot/sdk/transcript_builder.py b/autogpt_platform/backend/backend/copilot/sdk/transcript_builder.py index b0b7fa5502..5e971bf395 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/transcript_builder.py +++ b/autogpt_platform/backend/backend/copilot/sdk/transcript_builder.py @@ -1,235 +1,10 @@ -"""Build complete JSONL transcript from SDK messages. +"""Re-export from shared ``backend.copilot.transcript_builder`` for backward compat. -The transcript represents the FULL active context at any point in time. -Each upload REPLACES the previous transcript atomically. - -Flow: - Turn 1: Upload [msg1, msg2] - Turn 2: Download [msg1, msg2] → Upload [msg1, msg2, msg3, msg4] (REPLACE) - Turn 3: Download [msg1, msg2, msg3, msg4] → Upload [all messages] (REPLACE) - -The transcript is never incremental - always the complete atomic state. +The canonical implementation now lives at ``backend.copilot.transcript_builder`` +so both the SDK and baseline paths can import without cross-package +dependencies. """ -import logging -from typing import Any -from uuid import uuid4 +from backend.copilot.transcript_builder import TranscriptBuilder, TranscriptEntry -from pydantic import BaseModel - -from backend.util import json - -from .transcript import STRIPPABLE_TYPES - -logger = logging.getLogger(__name__) - - -class TranscriptEntry(BaseModel): - """Single transcript entry (user or assistant turn).""" - - type: str - uuid: str - parentUuid: str | None - isCompactSummary: bool | None = None - message: dict[str, Any] - - -class TranscriptBuilder: - """Build complete JSONL transcript from SDK messages. - - This builder maintains the FULL conversation state, not incremental changes. - The output is always the complete active context. - """ - - def __init__(self) -> None: - self._entries: list[TranscriptEntry] = [] - self._last_uuid: str | None = None - - def _last_is_assistant(self) -> bool: - return bool(self._entries) and self._entries[-1].type == "assistant" - - def _last_message_id(self) -> str: - """Return the message.id of the last entry, or '' if none.""" - if self._entries: - return self._entries[-1].message.get("id", "") - return "" - - @staticmethod - def _parse_entry(data: dict) -> TranscriptEntry | None: - """Parse a single transcript entry, filtering strippable types. - - Returns ``None`` for entries that should be skipped (strippable types - that are not compaction summaries). - """ - entry_type = data.get("type", "") - if entry_type in STRIPPABLE_TYPES and not data.get("isCompactSummary"): - return None - return TranscriptEntry( - type=entry_type, - uuid=data.get("uuid") or str(uuid4()), - parentUuid=data.get("parentUuid"), - isCompactSummary=data.get("isCompactSummary"), - message=data.get("message", {}), - ) - - def load_previous(self, content: str, log_prefix: str = "[Transcript]") -> None: - """Load complete previous transcript. - - This loads the FULL previous context. As new messages come in, - we append to this state. The final output is the complete context - (previous + new), not just the delta. - """ - if not content or not content.strip(): - return - - lines = content.strip().split("\n") - for line_num, line in enumerate(lines, 1): - if not line.strip(): - continue - - data = json.loads(line, fallback=None) - if data is None: - logger.warning( - "%s Failed to parse transcript line %d/%d", - log_prefix, - line_num, - len(lines), - ) - continue - - entry = self._parse_entry(data) - if entry is None: - continue - self._entries.append(entry) - self._last_uuid = entry.uuid - - logger.info( - "%s Loaded %d entries from previous transcript (last_uuid=%s)", - log_prefix, - len(self._entries), - self._last_uuid[:12] if self._last_uuid else None, - ) - - def append_user(self, content: str | list[dict], uuid: str | None = None) -> None: - """Append a user entry.""" - msg_uuid = uuid or str(uuid4()) - - self._entries.append( - TranscriptEntry( - type="user", - uuid=msg_uuid, - parentUuid=self._last_uuid, - message={"role": "user", "content": content}, - ) - ) - self._last_uuid = msg_uuid - - def append_tool_result(self, tool_use_id: str, content: str) -> None: - """Append a tool result as a user entry (one per tool call).""" - self.append_user( - content=[ - {"type": "tool_result", "tool_use_id": tool_use_id, "content": content} - ] - ) - - def append_assistant( - self, - content_blocks: list[dict], - model: str = "", - stop_reason: str | None = None, - ) -> None: - """Append an assistant entry. - - Consecutive assistant entries automatically share the same message ID - so the CLI can merge them (thinking → text → tool_use) into a single - API message on ``--resume``. A new ID is assigned whenever an - assistant entry follows a non-assistant entry (user message or tool - result), because that marks the start of a new API response. - """ - message_id = ( - self._last_message_id() - if self._last_is_assistant() - else f"msg_sdk_{uuid4().hex[:24]}" - ) - - msg_uuid = str(uuid4()) - - self._entries.append( - TranscriptEntry( - type="assistant", - uuid=msg_uuid, - parentUuid=self._last_uuid, - message={ - "role": "assistant", - "model": model, - "id": message_id, - "type": "message", - "content": content_blocks, - "stop_reason": stop_reason, - "stop_sequence": None, - }, - ) - ) - self._last_uuid = msg_uuid - - def replace_entries( - self, compacted_entries: list[dict], log_prefix: str = "[Transcript]" - ) -> None: - """Replace all entries with compacted entries from the CLI session file. - - Called after mid-stream compaction so TranscriptBuilder mirrors the - CLI's active context (compaction summary + post-compaction entries). - - Builds the new list first and validates it's non-empty before swapping, - so corrupt input cannot wipe the conversation history. - """ - new_entries: list[TranscriptEntry] = [] - for data in compacted_entries: - entry = self._parse_entry(data) - if entry is not None: - new_entries.append(entry) - - if not new_entries: - logger.warning( - "%s replace_entries produced 0 entries from %d inputs, keeping old (%d entries)", - log_prefix, - len(compacted_entries), - len(self._entries), - ) - return - - old_count = len(self._entries) - self._entries = new_entries - self._last_uuid = new_entries[-1].uuid - - logger.info( - "%s TranscriptBuilder compacted: %d entries -> %d entries", - log_prefix, - old_count, - len(self._entries), - ) - - def to_jsonl(self) -> str: - """Export complete context as JSONL. - - Consecutive assistant entries are kept separate to match the - native CLI format — the SDK merges them internally on resume. - - Returns the FULL conversation state (all entries), not incremental. - This output REPLACES any previous transcript. - """ - if not self._entries: - return "" - - lines = [entry.model_dump_json(exclude_none=True) for entry in self._entries] - return "\n".join(lines) + "\n" - - @property - def entry_count(self) -> int: - """Total number of entries in the complete context.""" - return len(self._entries) - - @property - def is_empty(self) -> bool: - """Whether this builder has any entries.""" - return len(self._entries) == 0 +__all__ = ["TranscriptBuilder", "TranscriptEntry"] diff --git a/autogpt_platform/backend/backend/copilot/sdk/transcript_test.py b/autogpt_platform/backend/backend/copilot/sdk/transcript_test.py index e70b3cedd9..cdc80d467d 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/transcript_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/transcript_test.py @@ -303,7 +303,7 @@ class TestDeleteTranscript: mock_storage.delete = AsyncMock() with patch( - "backend.copilot.sdk.transcript.get_workspace_storage", + "backend.copilot.transcript.get_workspace_storage", new_callable=AsyncMock, return_value=mock_storage, ): @@ -323,7 +323,7 @@ class TestDeleteTranscript: ) with patch( - "backend.copilot.sdk.transcript.get_workspace_storage", + "backend.copilot.transcript.get_workspace_storage", new_callable=AsyncMock, return_value=mock_storage, ): @@ -341,7 +341,7 @@ class TestDeleteTranscript: ) with patch( - "backend.copilot.sdk.transcript.get_workspace_storage", + "backend.copilot.transcript.get_workspace_storage", new_callable=AsyncMock, return_value=mock_storage, ): @@ -850,7 +850,7 @@ class TestRunCompression: @pytest.mark.asyncio async def test_no_client_uses_truncation(self): """Path (a): ``get_openai_client()`` returns None → truncation only.""" - from .transcript import _run_compression + from backend.copilot.transcript import _run_compression truncation_result = self._make_compress_result( True, [{"role": "user", "content": "truncated"}] @@ -858,11 +858,11 @@ class TestRunCompression: with ( patch( - "backend.copilot.sdk.transcript.get_openai_client", + "backend.copilot.transcript.get_openai_client", return_value=None, ), patch( - "backend.copilot.sdk.transcript.compress_context", + "backend.copilot.transcript.compress_context", new_callable=AsyncMock, return_value=truncation_result, ) as mock_compress, @@ -885,7 +885,7 @@ class TestRunCompression: @pytest.mark.asyncio async def test_llm_success_returns_llm_result(self): """Path (b): ``get_openai_client()`` returns a client → LLM compresses.""" - from .transcript import _run_compression + from backend.copilot.transcript import _run_compression llm_result = self._make_compress_result( True, [{"role": "user", "content": "LLM summary"}] @@ -894,11 +894,11 @@ class TestRunCompression: with ( patch( - "backend.copilot.sdk.transcript.get_openai_client", + "backend.copilot.transcript.get_openai_client", return_value=mock_client, ), patch( - "backend.copilot.sdk.transcript.compress_context", + "backend.copilot.transcript.compress_context", new_callable=AsyncMock, return_value=llm_result, ) as mock_compress, @@ -916,7 +916,7 @@ class TestRunCompression: @pytest.mark.asyncio async def test_llm_failure_falls_back_to_truncation(self): """Path (c): LLM call raises → truncation fallback used instead.""" - from .transcript import _run_compression + from backend.copilot.transcript import _run_compression truncation_result = self._make_compress_result( True, [{"role": "user", "content": "truncated fallback"}] @@ -932,11 +932,11 @@ class TestRunCompression: with ( patch( - "backend.copilot.sdk.transcript.get_openai_client", + "backend.copilot.transcript.get_openai_client", return_value=mock_client, ), patch( - "backend.copilot.sdk.transcript.compress_context", + "backend.copilot.transcript.compress_context", side_effect=_compress_side_effect, ), ): @@ -953,7 +953,7 @@ class TestRunCompression: @pytest.mark.asyncio async def test_llm_timeout_falls_back_to_truncation(self): """Path (d): LLM call exceeds timeout → truncation fallback used.""" - from .transcript import _run_compression + from backend.copilot.transcript import _run_compression truncation_result = self._make_compress_result( True, [{"role": "user", "content": "truncated after timeout"}] @@ -970,19 +970,19 @@ class TestRunCompression: fake_client = MagicMock() with ( patch( - "backend.copilot.sdk.transcript.get_openai_client", + "backend.copilot.transcript.get_openai_client", return_value=fake_client, ), patch( - "backend.copilot.sdk.transcript.compress_context", + "backend.copilot.transcript.compress_context", side_effect=_compress_side_effect, ), patch( - "backend.copilot.sdk.transcript._COMPACTION_TIMEOUT_SECONDS", + "backend.copilot.transcript._COMPACTION_TIMEOUT_SECONDS", 0.05, ), patch( - "backend.copilot.sdk.transcript._TRUNCATION_TIMEOUT_SECONDS", + "backend.copilot.transcript._TRUNCATION_TIMEOUT_SECONDS", 5, ), ): @@ -1007,7 +1007,7 @@ class TestCleanupStaleProjectDirs: def test_removes_old_copilot_dirs(self, tmp_path, monkeypatch): """Directories matching copilot pattern older than threshold are removed.""" - from backend.copilot.sdk.transcript import ( + from backend.copilot.transcript import ( _STALE_PROJECT_DIR_SECONDS, cleanup_stale_project_dirs, ) @@ -1015,7 +1015,7 @@ class TestCleanupStaleProjectDirs: projects_dir = tmp_path / "projects" projects_dir.mkdir() monkeypatch.setattr( - "backend.copilot.sdk.transcript._projects_base", + "backend.copilot.transcript._projects_base", lambda: str(projects_dir), ) @@ -1039,12 +1039,12 @@ class TestCleanupStaleProjectDirs: def test_ignores_non_copilot_dirs(self, tmp_path, monkeypatch): """Directories not matching copilot pattern are left alone.""" - from backend.copilot.sdk.transcript import cleanup_stale_project_dirs + from backend.copilot.transcript import cleanup_stale_project_dirs projects_dir = tmp_path / "projects" projects_dir.mkdir() monkeypatch.setattr( - "backend.copilot.sdk.transcript._projects_base", + "backend.copilot.transcript._projects_base", lambda: str(projects_dir), ) @@ -1062,7 +1062,7 @@ class TestCleanupStaleProjectDirs: def test_ttl_boundary_not_removed(self, tmp_path, monkeypatch): """A directory exactly at the TTL boundary should NOT be removed.""" - from backend.copilot.sdk.transcript import ( + from backend.copilot.transcript import ( _STALE_PROJECT_DIR_SECONDS, cleanup_stale_project_dirs, ) @@ -1070,7 +1070,7 @@ class TestCleanupStaleProjectDirs: projects_dir = tmp_path / "projects" projects_dir.mkdir() monkeypatch.setattr( - "backend.copilot.sdk.transcript._projects_base", + "backend.copilot.transcript._projects_base", lambda: str(projects_dir), ) @@ -1088,7 +1088,7 @@ class TestCleanupStaleProjectDirs: def test_skips_non_directory_entries(self, tmp_path, monkeypatch): """Regular files matching the copilot pattern are not removed.""" - from backend.copilot.sdk.transcript import ( + from backend.copilot.transcript import ( _STALE_PROJECT_DIR_SECONDS, cleanup_stale_project_dirs, ) @@ -1096,7 +1096,7 @@ class TestCleanupStaleProjectDirs: projects_dir = tmp_path / "projects" projects_dir.mkdir() monkeypatch.setattr( - "backend.copilot.sdk.transcript._projects_base", + "backend.copilot.transcript._projects_base", lambda: str(projects_dir), ) @@ -1114,11 +1114,11 @@ class TestCleanupStaleProjectDirs: def test_missing_base_dir_returns_zero(self, tmp_path, monkeypatch): """If the projects base directory doesn't exist, return 0 gracefully.""" - from backend.copilot.sdk.transcript import cleanup_stale_project_dirs + from backend.copilot.transcript import cleanup_stale_project_dirs nonexistent = str(tmp_path / "does-not-exist" / "projects") monkeypatch.setattr( - "backend.copilot.sdk.transcript._projects_base", + "backend.copilot.transcript._projects_base", lambda: nonexistent, ) @@ -1129,7 +1129,7 @@ class TestCleanupStaleProjectDirs: """When encoded_cwd is supplied only that directory is swept.""" import time - from backend.copilot.sdk.transcript import ( + from backend.copilot.transcript import ( _STALE_PROJECT_DIR_SECONDS, cleanup_stale_project_dirs, ) @@ -1137,7 +1137,7 @@ class TestCleanupStaleProjectDirs: projects_dir = tmp_path / "projects" projects_dir.mkdir() monkeypatch.setattr( - "backend.copilot.sdk.transcript._projects_base", + "backend.copilot.transcript._projects_base", lambda: str(projects_dir), ) @@ -1160,12 +1160,12 @@ class TestCleanupStaleProjectDirs: def test_scoped_fresh_dir_not_removed(self, tmp_path, monkeypatch): """Scoped sweep leaves a fresh directory alone.""" - from backend.copilot.sdk.transcript import cleanup_stale_project_dirs + from backend.copilot.transcript import cleanup_stale_project_dirs projects_dir = tmp_path / "projects" projects_dir.mkdir() monkeypatch.setattr( - "backend.copilot.sdk.transcript._projects_base", + "backend.copilot.transcript._projects_base", lambda: str(projects_dir), ) @@ -1181,7 +1181,7 @@ class TestCleanupStaleProjectDirs: """Scoped sweep refuses to remove a non-copilot directory.""" import time - from backend.copilot.sdk.transcript import ( + from backend.copilot.transcript import ( _STALE_PROJECT_DIR_SECONDS, cleanup_stale_project_dirs, ) @@ -1189,7 +1189,7 @@ class TestCleanupStaleProjectDirs: projects_dir = tmp_path / "projects" projects_dir.mkdir() monkeypatch.setattr( - "backend.copilot.sdk.transcript._projects_base", + "backend.copilot.transcript._projects_base", lambda: str(projects_dir), ) diff --git a/autogpt_platform/backend/backend/copilot/service_test.py b/autogpt_platform/backend/backend/copilot/service_test.py index d65b356f4a..c4b1c3182e 100644 --- a/autogpt_platform/backend/backend/copilot/service_test.py +++ b/autogpt_platform/backend/backend/copilot/service_test.py @@ -7,7 +7,7 @@ import pytest from .model import create_chat_session, get_chat_session, upsert_chat_session from .response_model import StreamError, StreamTextDelta from .sdk import service as sdk_service -from .sdk.transcript import download_transcript +from .transcript import download_transcript logger = logging.getLogger(__name__) diff --git a/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer.py b/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer.py index ce9c30dc3a..adebd89bf1 100644 --- a/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer.py +++ b/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer.py @@ -33,12 +33,23 @@ _GET_CURRENT_DATE_BLOCK_ID = "b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0b1" _GMAIL_SEND_BLOCK_ID = "6c27abc2-e51d-499e-a85f-5a0041ba94f0" _TEXT_REPLACE_BLOCK_ID = "7e7c87ab-3469-4bcc-9abe-67705091b713" +# Default OrchestratorBlock model/mode — kept in sync with ChatConfig.model. +# ChatConfig uses the OpenRouter format ("anthropic/claude-opus-4.6"); +# OrchestratorBlock uses the native Anthropic model name. +ORCHESTRATOR_DEFAULT_MODEL = "claude-opus-4-6" +ORCHESTRATOR_DEFAULT_EXECUTION_MODE = "extended_thinking" + # Defaults applied to OrchestratorBlock nodes by the fixer. -_SDM_DEFAULTS: dict[str, int | bool] = { +# execution_mode and model match the copilot's default (extended thinking +# with Opus) so generated agents inherit the same reasoning capabilities. +# If the user explicitly sets these fields, the fixer won't override them. +_SDM_DEFAULTS: dict[str, int | bool | str] = { "agent_mode_max_iterations": 10, "conversation_compaction": True, "retry": 3, "multiple_tool_calls": False, + "execution_mode": ORCHESTRATOR_DEFAULT_EXECUTION_MODE, + "model": ORCHESTRATOR_DEFAULT_MODEL, } @@ -879,6 +890,12 @@ class AgentFixer: ) if is_ai_block: + # Skip AI blocks that don't expose a "model" input property + # (some AI-category blocks have no model selector at all). + input_properties = block.get("inputSchema", {}).get("properties", {}) + if "model" not in input_properties: + continue + node_id = node.get("id") input_default = node.get("input_default", {}) current_model = input_default.get("model") @@ -887,9 +904,7 @@ class AgentFixer: # Blocks with a block-specific enum on the model field (e.g. # PerplexityBlock) use their own enum values; others use the # generic set. - model_schema = ( - block.get("inputSchema", {}).get("properties", {}).get("model", {}) - ) + model_schema = input_properties.get("model", {}) block_model_enum = model_schema.get("enum") if block_model_enum: @@ -1649,6 +1664,8 @@ class AgentFixer: 2. ``conversation_compaction`` defaults to ``True`` 3. ``retry`` defaults to ``3`` 4. ``multiple_tool_calls`` defaults to ``False`` + 5. ``execution_mode`` defaults to ``"extended_thinking"`` + 6. ``model`` defaults to ``"claude-opus-4-6"`` Args: agent: The agent dictionary to fix @@ -1748,6 +1765,12 @@ class AgentFixer: agent = self.fix_node_x_coordinates(agent, node_lookup=node_lookup) agent = self.fix_getcurrentdate_offset(agent) + # Apply OrchestratorBlock defaults BEFORE fix_ai_model_parameter so that + # the orchestrator-specific model (claude-opus-4-6) is set first and + # fix_ai_model_parameter sees it as a valid allowed model instead of + # overwriting it with the generic default (gpt-4o). + agent = self.fix_orchestrator_blocks(agent) + # Apply fixes that require blocks information if blocks: agent = self.fix_invalid_nested_sink_links( @@ -1765,9 +1788,6 @@ class AgentFixer: # Apply fixes for MCPToolBlock nodes agent = self.fix_mcp_tool_blocks(agent) - # Apply fixes for OrchestratorBlock nodes (agent-mode defaults) - agent = self.fix_orchestrator_blocks(agent) - # Apply fixes for AgentExecutorBlock nodes (sub-agents) if library_agents: agent = self.fix_agent_executor_blocks(agent, library_agents) diff --git a/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer_test.py b/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer_test.py index 07d71a941c..2319ad6760 100644 --- a/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer_test.py +++ b/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer_test.py @@ -580,6 +580,29 @@ class TestFixAiModelParameter: assert result["nodes"][0]["input_default"]["model"] == "perplexity/sonar" + def test_ai_block_without_model_property_is_skipped(self): + """AI-category blocks that have no 'model' input property should not + have a model injected — they simply don't expose a model selector.""" + fixer = AgentFixer() + block_id = generate_uuid() + node = _make_node(node_id="n1", block_id=block_id, input_default={}) + agent = _make_agent(nodes=[node]) + + blocks = [ + { + "id": block_id, + "name": "SomeAIBlock", + "categories": [{"category": "AI"}], + "inputSchema": { + "properties": {"prompt": {"type": "string"}}, + }, + } + ] + + result = fixer.fix_ai_model_parameter(agent, blocks) + + assert "model" not in result["nodes"][0]["input_default"] + class TestFixAgentExecutorBlocks: """Tests for fix_agent_executor_blocks.""" diff --git a/autogpt_platform/backend/backend/copilot/tools/get_agent_building_guide.py b/autogpt_platform/backend/backend/copilot/tools/get_agent_building_guide.py index 2fc733ceb2..0db8e0453c 100644 --- a/autogpt_platform/backend/backend/copilot/tools/get_agent_building_guide.py +++ b/autogpt_platform/backend/backend/copilot/tools/get_agent_building_guide.py @@ -42,7 +42,10 @@ class GetAgentBuildingGuideTool(BaseTool): @property def description(self) -> str: - return "Get the agent JSON building guide (nodes, links, AgentExecutorBlock, MCPToolBlock usage). Call before generating agent JSON." + return ( + "Get the agent JSON building guide (nodes, links, AgentExecutorBlock, MCPToolBlock usage, " + "and the create->dry-run->fix iterative workflow). Call before generating agent JSON." + ) @property def parameters(self) -> dict[str, Any]: diff --git a/autogpt_platform/backend/backend/copilot/tools/get_agent_building_guide_test.py b/autogpt_platform/backend/backend/copilot/tools/get_agent_building_guide_test.py new file mode 100644 index 0000000000..261247ee72 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/tools/get_agent_building_guide_test.py @@ -0,0 +1,15 @@ +"""Tests for GetAgentBuildingGuideTool.""" + +from backend.copilot.tools.get_agent_building_guide import _load_guide + + +def test_load_guide_returns_string(): + guide = _load_guide() + assert isinstance(guide, str) + assert len(guide) > 100 + + +def test_load_guide_caches(): + guide1 = _load_guide() + guide2 = _load_guide() + assert guide1 is guide2 diff --git a/autogpt_platform/backend/backend/copilot/tools/helpers.py b/autogpt_platform/backend/backend/copilot/tools/helpers.py index 8ea7650b4a..cc45a3f63e 100644 --- a/autogpt_platform/backend/backend/copilot/tools/helpers.py +++ b/autogpt_platform/backend/backend/copilot/tools/helpers.py @@ -48,27 +48,41 @@ logger = logging.getLogger(__name__) def get_inputs_from_schema( input_schema: dict[str, Any], exclude_fields: set[str] | None = None, + input_data: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: - """Extract input field info from JSON schema.""" + """Extract input field info from JSON schema. + + When *input_data* is provided, each field's ``value`` key is populated + with the value the CoPilot already supplied — so the frontend can + prefill the form instead of showing empty inputs. Fields marked + ``advanced`` in the schema are flagged so the frontend can hide them + by default (matching the builder behaviour). + """ if not isinstance(input_schema, dict): return [] exclude = exclude_fields or set() properties = input_schema.get("properties", {}) required = set(input_schema.get("required", [])) + provided = input_data or {} - return [ - { + results: list[dict[str, Any]] = [] + for name, schema in properties.items(): + if name in exclude: + continue + entry: dict[str, Any] = { "name": name, "title": schema.get("title", name), "type": schema.get("type", "string"), "description": schema.get("description", ""), "required": name in required, "default": schema.get("default"), + "advanced": schema.get("advanced", False), } - for name, schema in properties.items() - if name not in exclude - ] + if name in provided: + entry["value"] = provided[name] + results.append(entry) + return results async def execute_block( @@ -446,7 +460,9 @@ async def prepare_block_for_execution( requirements={ "credentials": missing_creds_list, "inputs": get_inputs_from_schema( - input_schema, exclude_fields=credentials_fields + input_schema, + exclude_fields=credentials_fields, + input_data=input_data, ), "execution_modes": ["immediate"], }, diff --git a/autogpt_platform/backend/backend/copilot/tools/run_agent.py b/autogpt_platform/backend/backend/copilot/tools/run_agent.py index 65ea76dd26..d056e1a5af 100644 --- a/autogpt_platform/backend/backend/copilot/tools/run_agent.py +++ b/autogpt_platform/backend/backend/copilot/tools/run_agent.py @@ -153,7 +153,11 @@ class RunAgentTool(BaseTool): }, "dry_run": { "type": "boolean", - "description": "Execute in preview mode.", + "description": ( + "When true, simulates execution using an LLM for each block " + "— no real API calls, credentials, or credits. " + "See agent_generation_guide for the full workflow." + ), }, }, "required": ["dry_run"], diff --git a/autogpt_platform/backend/backend/copilot/tools/workspace_files.py b/autogpt_platform/backend/backend/copilot/tools/workspace_files.py index def2d4772a..a5fe549923 100644 --- a/autogpt_platform/backend/backend/copilot/tools/workspace_files.py +++ b/autogpt_platform/backend/backend/copilot/tools/workspace_files.py @@ -845,6 +845,7 @@ class WriteWorkspaceFileTool(BaseTool): path=path, mime_type=mime_type, overwrite=overwrite, + metadata={"origin": "agent-created"}, ) # Build informative source label and message. diff --git a/autogpt_platform/backend/backend/copilot/transcript.py b/autogpt_platform/backend/backend/copilot/transcript.py new file mode 100644 index 0000000000..7f961a116f --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/transcript.py @@ -0,0 +1,1247 @@ +"""JSONL transcript management for stateless multi-turn resume. + +The Claude Code CLI persists conversations as JSONL files (one JSON object per +line). When the SDK's ``Stop`` hook fires we read this file, strip bloat +(progress entries, metadata), and upload the result to bucket storage. On the +next turn we download the transcript, write it to a temp file, and pass +``--resume`` so the CLI can reconstruct the full conversation. + +Storage is handled via ``WorkspaceStorageBackend`` (GCS in prod, local +filesystem for self-hosted) — no DB column needed. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import shutil +import time +from dataclasses import dataclass +from pathlib import Path +from uuid import uuid4 + +from backend.util import json +from backend.util.clients import get_openai_client +from backend.util.prompt import CompressResult, compress_context +from backend.util.workspace_storage import GCSWorkspaceStorage, get_workspace_storage + +logger = logging.getLogger(__name__) + +# UUIDs are hex + hyphens; strip everything else to prevent path injection. +_SAFE_ID_RE = re.compile(r"[^0-9a-fA-F-]") + +# Entry types that can be safely removed from the transcript without breaking +# the parentUuid conversation tree that ``--resume`` relies on. +# - progress: UI progress ticks, no message content (avg 97KB for agent_progress) +# - file-history-snapshot: undo tracking metadata +# - queue-operation: internal queue bookkeeping +# - summary: session summaries +# - pr-link: PR link metadata +STRIPPABLE_TYPES = frozenset( + {"progress", "file-history-snapshot", "queue-operation", "summary", "pr-link"} +) + + +@dataclass +class TranscriptDownload: + """Result of downloading a transcript with its metadata.""" + + content: str + message_count: int = 0 # session.messages length when uploaded + uploaded_at: float = 0.0 # epoch timestamp of upload + + +# Workspace storage constants — deterministic path from session_id. +TRANSCRIPT_STORAGE_PREFIX = "chat-transcripts" + + +# --------------------------------------------------------------------------- +# Progress stripping +# --------------------------------------------------------------------------- + + +def strip_progress_entries(content: str) -> str: + """Remove progress/metadata entries from a JSONL transcript. + + Removes entries whose ``type`` is in ``STRIPPABLE_TYPES`` and reparents + any remaining child entries so the ``parentUuid`` chain stays intact. + Typically reduces transcript size by ~30%. + + Entries that are not stripped or reparented are kept as their original + raw JSON line to avoid unnecessary re-serialization that changes + whitespace or key ordering. + """ + lines = content.strip().split("\n") + + # Parse entries, keeping the original line alongside the parsed dict. + parsed: list[tuple[str, dict | None]] = [] + for line in lines: + parsed.append((line, json.loads(line, fallback=None))) + + # First pass: identify stripped UUIDs and build parent map. + stripped_uuids: set[str] = set() + uuid_to_parent: dict[str, str] = {} + + for _line, entry in parsed: + if not isinstance(entry, dict): + continue + uid = entry.get("uuid", "") + parent = entry.get("parentUuid", "") + if uid: + uuid_to_parent[uid] = parent + if ( + entry.get("type", "") in STRIPPABLE_TYPES + and uid + and not entry.get("isCompactSummary") + ): + stripped_uuids.add(uid) + + # Second pass: keep non-stripped entries, reparenting where needed. + # Preserve original line when no reparenting is required. + reparented: set[str] = set() + for _line, entry in parsed: + if not isinstance(entry, dict): + continue + parent = entry.get("parentUuid", "") + original_parent = parent + # seen_parents is local per-entry (not shared across iterations) so + # it can only detect cycles within a single ancestry walk, not across + # entries. This is intentional: each entry's parent chain is + # independent, and reusing a global set would incorrectly short-circuit + # valid re-use of the same UUID as a parent in different subtrees. + seen_parents: set[str] = set() + while parent in stripped_uuids and parent not in seen_parents: + seen_parents.add(parent) + parent = uuid_to_parent.get(parent, "") + if parent != original_parent: + entry["parentUuid"] = parent + uid = entry.get("uuid", "") + if uid: + reparented.add(uid) + + result_lines: list[str] = [] + for line, entry in parsed: + if not isinstance(entry, dict): + result_lines.append(line) + continue + if entry.get("type", "") in STRIPPABLE_TYPES and not entry.get( + "isCompactSummary" + ): + continue + uid = entry.get("uuid", "") + if uid in reparented: + # Re-serialize only entries whose parentUuid was changed. + result_lines.append(json.dumps(entry, separators=(",", ":"))) + else: + result_lines.append(line) + + return "\n".join(result_lines) + "\n" + + +def strip_stale_thinking_blocks(content: str) -> str: + """Remove thinking/redacted_thinking blocks from non-last assistant entries. + + The Anthropic API only requires thinking blocks in the **last** assistant + message to be value-identical to the original response. Older assistant + entries carry stale thinking blocks that consume significant tokens + (often 10-50K each) without providing useful context for ``--resume``. + + Stripping them before upload prevents the CLI from triggering compaction + every turn just to compress away the stale thinking bloat. + """ + lines = content.strip().split("\n") + if not lines: + return content + + parsed: list[tuple[str, dict | None]] = [] + for line in lines: + parsed.append((line, json.loads(line, fallback=None))) + + # Reverse scan to find the last assistant message ID and index. + last_asst_msg_id: str | None = None + last_asst_idx: int | None = None + for i in range(len(parsed) - 1, -1, -1): + _line, entry = parsed[i] + if not isinstance(entry, dict): + continue + msg = entry.get("message", {}) + if msg.get("role") == "assistant": + last_asst_msg_id = msg.get("id") + last_asst_idx = i + break + + if last_asst_idx is None: + return content + + result_lines: list[str] = [] + stripped_count = 0 + for i, (line, entry) in enumerate(parsed): + if not isinstance(entry, dict): + result_lines.append(line) + continue + + msg = entry.get("message", {}) + # Only strip from assistant entries that are NOT the last turn. + # Use msg_id matching when available; fall back to index for entries + # without an id field. + is_last_turn = ( + last_asst_msg_id is not None and msg.get("id") == last_asst_msg_id + ) or (last_asst_msg_id is None and i == last_asst_idx) + if ( + msg.get("role") == "assistant" + and not is_last_turn + and isinstance(msg.get("content"), list) + ): + content_blocks = msg["content"] + filtered = [ + b + for b in content_blocks + if not (isinstance(b, dict) and b.get("type") in _THINKING_BLOCK_TYPES) + ] + if len(filtered) < len(content_blocks): + stripped_count += len(content_blocks) - len(filtered) + entry = {**entry, "message": {**msg, "content": filtered}} + result_lines.append(json.dumps(entry, separators=(",", ":"))) + continue + + result_lines.append(line) + + if stripped_count: + logger.info( + "[Transcript] Stripped %d stale thinking block(s) from non-last entries", + stripped_count, + ) + + return "\n".join(result_lines) + "\n" + + +def strip_for_upload(content: str) -> str: + """Combined single-parse strip of progress entries and stale thinking blocks. + + Equivalent to ``strip_stale_thinking_blocks(strip_progress_entries(content))`` + but parses the JSONL only once, avoiding redundant ``split`` + ``json.loads`` + passes on every upload. + """ + lines = content.strip().split("\n") + if not lines: + return content + + parsed: list[tuple[str, dict | None]] = [] + for line in lines: + parsed.append((line, json.loads(line, fallback=None))) + + # --- Phase 1: progress stripping (reparent children) --- + stripped_uuids: set[str] = set() + uuid_to_parent: dict[str, str] = {} + + for _line, entry in parsed: + if not isinstance(entry, dict): + continue + uid = entry.get("uuid", "") + parent = entry.get("parentUuid", "") + if uid: + uuid_to_parent[uid] = parent + if ( + entry.get("type", "") in STRIPPABLE_TYPES + and uid + and not entry.get("isCompactSummary") + ): + stripped_uuids.add(uid) + + reparented: set[str] = set() + for _line, entry in parsed: + if not isinstance(entry, dict): + continue + parent = entry.get("parentUuid", "") + original_parent = parent + seen_parents: set[str] = set() + while parent in stripped_uuids and parent not in seen_parents: + seen_parents.add(parent) + parent = uuid_to_parent.get(parent, "") + if parent != original_parent: + entry["parentUuid"] = parent + uid = entry.get("uuid", "") + if uid: + reparented.add(uid) + + # --- Phase 2: identify last assistant for thinking-block stripping --- + last_asst_msg_id: str | None = None + last_asst_idx: int | None = None + for i in range(len(parsed) - 1, -1, -1): + _line, entry = parsed[i] + if not isinstance(entry, dict): + continue + if entry.get("type", "") in STRIPPABLE_TYPES and not entry.get( + "isCompactSummary" + ): + continue + msg = entry.get("message", {}) + if msg.get("role") == "assistant": + last_asst_msg_id = msg.get("id") + last_asst_idx = i + break + + # --- Phase 3: single output pass --- + result_lines: list[str] = [] + thinking_stripped = 0 + for i, (line, entry) in enumerate(parsed): + if not isinstance(entry, dict): + result_lines.append(line) + continue + + # Drop progress/metadata entries + if entry.get("type", "") in STRIPPABLE_TYPES and not entry.get( + "isCompactSummary" + ): + continue + + needs_reserialize = False + uid = entry.get("uuid", "") + + # Reparented entries need re-serialization + if uid in reparented: + needs_reserialize = True + + # Strip stale thinking blocks from non-last assistant entries + if last_asst_idx is not None: + msg = entry.get("message", {}) + is_last_turn = ( + last_asst_msg_id is not None and msg.get("id") == last_asst_msg_id + ) or (last_asst_msg_id is None and i == last_asst_idx) + if ( + msg.get("role") == "assistant" + and not is_last_turn + and isinstance(msg.get("content"), list) + ): + content_blocks = msg["content"] + filtered = [ + b + for b in content_blocks + if not ( + isinstance(b, dict) and b.get("type") in _THINKING_BLOCK_TYPES + ) + ] + if len(filtered) < len(content_blocks): + thinking_stripped += len(content_blocks) - len(filtered) + entry = {**entry, "message": {**msg, "content": filtered}} + needs_reserialize = True + + if needs_reserialize: + result_lines.append(json.dumps(entry, separators=(",", ":"))) + else: + result_lines.append(line) + + if thinking_stripped: + logger.info( + "[Transcript] Stripped %d stale thinking block(s) from non-last entries", + thinking_stripped, + ) + + return "\n".join(result_lines) + "\n" + + +# --------------------------------------------------------------------------- +# Local file I/O (write temp file for --resume) +# --------------------------------------------------------------------------- + + +def _sanitize_id(raw_id: str, max_len: int = 36) -> str: + """Sanitize an ID for safe use in file paths. + + Session/user IDs are expected to be UUIDs (hex + hyphens). Strip + everything else and truncate to *max_len* so the result cannot introduce + path separators or other special characters. + """ + cleaned = _SAFE_ID_RE.sub("", raw_id or "")[:max_len] + return cleaned or "unknown" + + +_SAFE_CWD_PREFIX = os.path.realpath("/tmp/copilot-") + + +def _projects_base() -> str: + """Return the resolved path to the CLI's projects directory.""" + config_dir = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") + return os.path.realpath(os.path.join(config_dir, "projects")) + + +_STALE_PROJECT_DIR_SECONDS = 12 * 3600 # 12 hours — matches max session lifetime +_MAX_PROJECT_DIRS_TO_SWEEP = 50 # limit per sweep to avoid long pauses + + +def cleanup_stale_project_dirs(encoded_cwd: str | None = None) -> int: + """Remove CLI project directories older than ``_STALE_PROJECT_DIR_SECONDS``. + + Each CoPilot SDK turn creates a unique ``~/.claude/projects//`` + directory. These are intentionally kept across turns so the model can read + tool-result files via ``--resume``. However, after a session ends they + become stale. This function sweeps old ones to prevent unbounded disk + growth. + + When *encoded_cwd* is provided the sweep is scoped to that single + directory, making the operation safe in multi-tenant environments where + multiple copilot sessions share the same host. Without it the function + falls back to sweeping all directories matching the copilot naming pattern + (``-tmp-copilot-``), which is only safe for single-tenant deployments. + + Returns the number of directories removed. + """ + projects_base = _projects_base() + if not os.path.isdir(projects_base): + return 0 + + now = time.time() + removed = 0 + + # Scoped mode: only clean up the one directory for the current session. + if encoded_cwd: + target = Path(projects_base) / encoded_cwd + if not target.is_dir(): + return 0 + # Guard: only sweep copilot-generated dirs. + if "-tmp-copilot-" not in target.name: + logger.warning( + "[Transcript] Refusing to sweep non-copilot dir: %s", target.name + ) + return 0 + try: + # st_mtime is used as a proxy for session activity. Claude CLI writes + # its JSONL transcript into this directory during each turn, so mtime + # advances on every turn. A directory whose mtime is older than + # _STALE_PROJECT_DIR_SECONDS has not had an active turn in that window + # and is safe to remove (the session cannot --resume after cleanup). + age = now - target.stat().st_mtime + except OSError: + return 0 + if age < _STALE_PROJECT_DIR_SECONDS: + return 0 + try: + shutil.rmtree(target, ignore_errors=True) + removed = 1 + except OSError: + pass + if removed: + logger.info( + "[Transcript] Swept stale CLI project dir %s (age %ds > %ds)", + target.name, + int(age), + _STALE_PROJECT_DIR_SECONDS, + ) + return removed + + # Unscoped fallback: sweep all copilot dirs across the projects base. + # Only safe for single-tenant deployments; callers should prefer the + # scoped variant by passing encoded_cwd. + try: + entries = Path(projects_base).iterdir() + except OSError as e: + logger.warning("[Transcript] Failed to list projects dir: %s", e) + return 0 + + for entry in entries: + if removed >= _MAX_PROJECT_DIRS_TO_SWEEP: + break + # Only sweep copilot-generated dirs (pattern: -tmp-copilot- or + # -private-tmp-copilot-). + if "-tmp-copilot-" not in entry.name: + continue + if not entry.is_dir(): + continue + try: + # See the scoped-mode comment above: st_mtime advances on every turn, + # so a stale mtime reliably indicates an inactive session. + age = now - entry.stat().st_mtime + except OSError: + continue + if age < _STALE_PROJECT_DIR_SECONDS: + continue + + try: + shutil.rmtree(entry, ignore_errors=True) + removed += 1 + except OSError: + pass + + if removed: + logger.info( + "[Transcript] Swept %d stale CLI project dirs (older than %ds)", + removed, + _STALE_PROJECT_DIR_SECONDS, + ) + return removed + + +def read_compacted_entries(transcript_path: str) -> list[dict] | None: + """Read compacted entries from the CLI session file after compaction. + + Parses the JSONL file line-by-line, finds the ``isCompactSummary: true`` + entry, and returns it plus all entries after it. + + The CLI writes the compaction summary BEFORE sending the next message, + so the file is guaranteed to be flushed by the time we read it. + + Returns a list of parsed dicts, or ``None`` if the file cannot be read + or no compaction summary is found. + """ + if not transcript_path: + return None + + projects_base = _projects_base() + real_path = os.path.realpath(transcript_path) + if not real_path.startswith(projects_base + os.sep): + logger.warning( + "[Transcript] transcript_path outside projects base: %s", transcript_path + ) + return None + + try: + content = Path(real_path).read_text() + except OSError as e: + logger.warning( + "[Transcript] Failed to read session file %s: %s", transcript_path, e + ) + return None + + lines = content.strip().split("\n") + compact_idx: int | None = None + + for idx, line in enumerate(lines): + if not line.strip(): + continue + entry = json.loads(line, fallback=None) + if not isinstance(entry, dict): + continue + if entry.get("isCompactSummary"): + compact_idx = idx # don't break — find the LAST summary + + if compact_idx is None: + logger.debug("[Transcript] No compaction summary found in %s", transcript_path) + return None + + entries: list[dict] = [] + for line in lines[compact_idx:]: + if not line.strip(): + continue + entry = json.loads(line, fallback=None) + if isinstance(entry, dict): + entries.append(entry) + + logger.info( + "[Transcript] Read %d compacted entries from %s (summary at line %d)", + len(entries), + transcript_path, + compact_idx + 1, + ) + return entries + + +def write_transcript_to_tempfile( + transcript_content: str, + session_id: str, + cwd: str, +) -> str | None: + """Write JSONL transcript to a temp file inside *cwd* for ``--resume``. + + The file lives in the session working directory so it is cleaned up + automatically when the session ends. + + Returns the absolute path to the file, or ``None`` on failure. + """ + # Validate cwd is under the expected sandbox prefix (CodeQL sanitizer). + real_cwd = os.path.realpath(cwd) + if not real_cwd.startswith(_SAFE_CWD_PREFIX): + logger.warning("[Transcript] cwd outside sandbox: %s", cwd) + return None + + try: + os.makedirs(real_cwd, exist_ok=True) + safe_id = _sanitize_id(session_id, max_len=8) + jsonl_path = os.path.realpath( + os.path.join(real_cwd, f"transcript-{safe_id}.jsonl") + ) + if not jsonl_path.startswith(real_cwd): + logger.warning("[Transcript] Path escaped cwd: %s", jsonl_path) + return None + + with open(jsonl_path, "w") as f: + f.write(transcript_content) + + logger.info("[Transcript] Wrote resume file: %s", jsonl_path) + return jsonl_path + + except OSError as e: + logger.warning("[Transcript] Failed to write resume file: %s", e) + return None + + +def validate_transcript(content: str | None) -> bool: + """Check that a transcript has actual conversation messages. + + A valid transcript needs at least one assistant message (not just + queue-operation / file-history-snapshot metadata). We do NOT require + a ``type: "user"`` entry because with ``--resume`` the user's message + is passed as a CLI query parameter and does not appear in the + transcript file. + """ + if not content or not content.strip(): + return False + + lines = content.strip().split("\n") + + has_assistant = False + + for line in lines: + if not line.strip(): + continue + entry = json.loads(line, fallback=None) + if not isinstance(entry, dict): + return False + if entry.get("type") == "assistant": + has_assistant = True + + return has_assistant + + +# --------------------------------------------------------------------------- +# Bucket storage (GCS / local via WorkspaceStorageBackend) +# --------------------------------------------------------------------------- + + +def _storage_path_parts(user_id: str, session_id: str) -> tuple[str, str, str]: + """Return (workspace_id, file_id, filename) for a session's transcript. + + Path structure: ``chat-transcripts/{user_id}/{session_id}.jsonl`` + IDs are sanitized to hex+hyphen to prevent path traversal. + """ + return ( + TRANSCRIPT_STORAGE_PREFIX, + _sanitize_id(user_id), + f"{_sanitize_id(session_id)}.jsonl", + ) + + +def _meta_storage_path_parts(user_id: str, session_id: str) -> tuple[str, str, str]: + """Return (workspace_id, file_id, filename) for a session's transcript metadata.""" + return ( + TRANSCRIPT_STORAGE_PREFIX, + _sanitize_id(user_id), + f"{_sanitize_id(session_id)}.meta.json", + ) + + +def _build_path_from_parts(parts: tuple[str, str, str], backend: object) -> str: + """Build a full storage path from (workspace_id, file_id, filename) parts.""" + wid, fid, fname = parts + if isinstance(backend, GCSWorkspaceStorage): + blob = f"workspaces/{wid}/{fid}/{fname}" + return f"gcs://{backend.bucket_name}/{blob}" + return f"local://{wid}/{fid}/{fname}" + + +def _build_storage_path(user_id: str, session_id: str, backend: object) -> str: + """Build the full storage path string that ``retrieve()`` expects.""" + return _build_path_from_parts(_storage_path_parts(user_id, session_id), backend) + + +def _build_meta_storage_path(user_id: str, session_id: str, backend: object) -> str: + """Build the full storage path for the companion .meta.json file.""" + return _build_path_from_parts( + _meta_storage_path_parts(user_id, session_id), backend + ) + + +async def upload_transcript( + user_id: str, + session_id: str, + content: str, + message_count: int = 0, + log_prefix: str = "[Transcript]", + skip_strip: bool = False, +) -> None: + """Strip progress entries and stale thinking blocks, then upload transcript. + + The transcript represents the FULL active context (atomic). + Each upload REPLACES the previous transcript entirely. + + The executor holds a cluster lock per session, so concurrent uploads for + the same session cannot happen. + + Args: + content: Complete JSONL transcript (from TranscriptBuilder). + message_count: ``len(session.messages)`` at upload time. + skip_strip: When ``True``, skip the strip + re-validate pass. + Safe for builder-generated content (baseline path) which + never emits progress entries or stale thinking blocks. + """ + if skip_strip: + # Caller guarantees the content is already clean and valid. + stripped = content + else: + # Strip metadata entries and stale thinking blocks in a single parse. + # SDK-built transcripts may have progress entries; strip for safety. + stripped = strip_for_upload(content) + if not skip_strip and not validate_transcript(stripped): + # Log entry types for debugging — helps identify why validation failed + entry_types = [ + json.loads(line, fallback={"type": "INVALID_JSON"}).get("type", "?") + for line in stripped.strip().split("\n") + ] + logger.warning( + "%s Skipping upload — stripped content not valid " + "(types=%s, stripped_len=%d, raw_len=%d)", + log_prefix, + entry_types, + len(stripped), + len(content), + ) + logger.debug("%s Raw content preview: %s", log_prefix, content[:500]) + logger.debug("%s Stripped content: %s", log_prefix, stripped[:500]) + return + + storage = await get_workspace_storage() + wid, fid, fname = _storage_path_parts(user_id, session_id) + encoded = stripped.encode("utf-8") + meta = {"message_count": message_count, "uploaded_at": time.time()} + mwid, mfid, mfname = _meta_storage_path_parts(user_id, session_id) + meta_encoded = json.dumps(meta).encode("utf-8") + + # Transcript + metadata are independent objects at different keys, so + # write them concurrently. ``return_exceptions`` keeps a metadata + # failure from sinking the transcript write. + transcript_result, metadata_result = await asyncio.gather( + storage.store( + workspace_id=wid, + file_id=fid, + filename=fname, + content=encoded, + ), + storage.store( + workspace_id=mwid, + file_id=mfid, + filename=mfname, + content=meta_encoded, + ), + return_exceptions=True, + ) + if isinstance(transcript_result, BaseException): + raise transcript_result + if isinstance(metadata_result, BaseException): + # Metadata is best-effort — the gap-fill logic in + # _build_query_message tolerates a missing metadata file. + logger.warning("%s Failed to write metadata: %s", log_prefix, metadata_result) + + logger.info( + "%s Uploaded %dB (stripped from %dB, msg_count=%d)", + log_prefix, + len(encoded), + len(content), + message_count, + ) + + +async def download_transcript( + user_id: str, + session_id: str, + log_prefix: str = "[Transcript]", +) -> TranscriptDownload | None: + """Download transcript and metadata from bucket storage. + + Returns a ``TranscriptDownload`` with the JSONL content and the + ``message_count`` watermark from the upload, or ``None`` if not found. + + The content and metadata fetches run concurrently since they are + independent objects in the bucket. + """ + storage = await get_workspace_storage() + path = _build_storage_path(user_id, session_id, storage) + meta_path = _build_meta_storage_path(user_id, session_id, storage) + + content_task = asyncio.create_task(storage.retrieve(path)) + meta_task = asyncio.create_task(storage.retrieve(meta_path)) + content_result, meta_result = await asyncio.gather( + content_task, meta_task, return_exceptions=True + ) + + if isinstance(content_result, FileNotFoundError): + logger.debug("%s No transcript in storage", log_prefix) + return None + if isinstance(content_result, BaseException): + logger.warning( + "%s Failed to download transcript: %s", log_prefix, content_result + ) + return None + + content = content_result.decode("utf-8") + + # Metadata is best-effort — old transcripts won't have it. + message_count = 0 + uploaded_at = 0.0 + if isinstance(meta_result, FileNotFoundError): + pass # No metadata — treat as unknown (msg_count=0 → always fill gap) + elif isinstance(meta_result, BaseException): + logger.debug( + "%s Failed to load transcript metadata: %s", log_prefix, meta_result + ) + else: + meta = json.loads(meta_result.decode("utf-8"), fallback={}) + message_count = meta.get("message_count", 0) + uploaded_at = meta.get("uploaded_at", 0.0) + + logger.info( + "%s Downloaded %dB (msg_count=%d)", log_prefix, len(content), message_count + ) + return TranscriptDownload( + content=content, + message_count=message_count, + uploaded_at=uploaded_at, + ) + + +async def delete_transcript(user_id: str, session_id: str) -> None: + """Delete transcript and its metadata from bucket storage. + + Removes both the ``.jsonl`` transcript and the companion ``.meta.json`` + so stale ``message_count`` watermarks cannot corrupt gap-fill logic. + """ + storage = await get_workspace_storage() + path = _build_storage_path(user_id, session_id, storage) + + try: + await storage.delete(path) + logger.info("[Transcript] Deleted transcript for session %s", session_id) + except Exception as e: + logger.warning("[Transcript] Failed to delete transcript: %s", e) + + # Also delete the companion .meta.json to avoid orphaned metadata. + try: + meta_path = _build_meta_storage_path(user_id, session_id, storage) + await storage.delete(meta_path) + logger.info("[Transcript] Deleted metadata for session %s", session_id) + except Exception as e: + logger.warning("[Transcript] Failed to delete metadata: %s", e) + + +# --------------------------------------------------------------------------- +# Transcript compaction — LLM summarization for prompt-too-long recovery +# --------------------------------------------------------------------------- + +# JSONL protocol values used in transcript serialization. +STOP_REASON_END_TURN = "end_turn" +STOP_REASON_TOOL_USE = "tool_use" +COMPACT_MSG_ID_PREFIX = "msg_compact_" +ENTRY_TYPE_MESSAGE = "message" + + +_THINKING_BLOCK_TYPES = frozenset({"thinking", "redacted_thinking"}) + + +def _flatten_assistant_content(blocks: list) -> str: + """Flatten assistant content blocks into a single plain-text string. + + Structured ``tool_use`` blocks are converted to ``[tool_use: name]`` + placeholders. ``thinking`` and ``redacted_thinking`` blocks are + silently dropped — they carry no useful context for compression + summaries and must not leak into compacted transcripts (the Anthropic + API requires thinking blocks in the last assistant message to be + value-identical to the original response; including stale thinking + text would violate that constraint). + + This is intentional: ``compress_context`` requires plain text for + token counting and LLM summarization. The structural loss is + acceptable because compaction only runs when the original transcript + was already too large for the model. + """ + parts: list[str] = [] + for block in blocks: + if isinstance(block, dict): + btype = block.get("type", "") + if btype in _THINKING_BLOCK_TYPES: + continue + if btype == "text": + parts.append(block.get("text", "")) + elif btype == "tool_use": + # Drop tool_use entirely — any text representation gets + # mimicked by the model as plain text instead of actual + # structured tool calls. The tool results (in the + # following user/tool_result entry) provide sufficient + # context about what happened. + continue + else: + continue + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) if parts else "" + + +def _flatten_tool_result_content(blocks: list) -> str: + """Flatten tool_result and other content blocks into plain text. + + Handles nested tool_result structures, text blocks, and raw strings. + Uses ``json.dumps`` as fallback for dict blocks without a ``text`` key + or where ``text`` is ``None``. + + Like ``_flatten_assistant_content``, structured blocks (images, nested + tool results) are reduced to text representations for compression. + """ + str_parts: list[str] = [] + for block in blocks: + if isinstance(block, dict) and block.get("type") == "tool_result": + inner = block.get("content") or "" + if isinstance(inner, list): + for sub in inner: + if isinstance(sub, dict): + sub_type = sub.get("type") + if sub_type in ("image", "document"): + # Avoid serializing base64 binary data into + # the compaction input — use a placeholder. + str_parts.append(f"[__{sub_type}__]") + elif sub_type == "text" or sub.get("text") is not None: + str_parts.append(str(sub.get("text", ""))) + else: + str_parts.append(json.dumps(sub)) + else: + str_parts.append(str(sub)) + else: + str_parts.append(str(inner)) + elif isinstance(block, dict) and block.get("type") == "text": + str_parts.append(str(block.get("text", ""))) + elif isinstance(block, dict): + # Preserve non-text/non-tool_result blocks (e.g. image) as placeholders. + # Use __prefix__ to distinguish from literal user text. + btype = block.get("type", "unknown") + str_parts.append(f"[__{btype}__]") + elif isinstance(block, str): + str_parts.append(block) + return "\n".join(str_parts) if str_parts else "" + + +def _transcript_to_messages(content: str) -> list[dict]: + """Convert JSONL transcript entries to plain message dicts for compression. + + Parses each line of the JSONL *content*, skips strippable metadata entries + (progress, file-history-snapshot, etc.), and extracts the ``role`` and + flattened ``content`` from the ``message`` field of each remaining entry. + + Structured content blocks (``tool_use``, ``tool_result``, images) are + flattened to plain text via ``_flatten_assistant_content`` and + ``_flatten_tool_result_content`` so that ``compress_context`` can + perform token counting and LLM summarization on uniform strings. + + Returns: + A list of ``{"role": str, "content": str}`` dicts suitable for + ``compress_context``. + """ + messages: list[dict] = [] + for line in content.strip().split("\n"): + if not line.strip(): + continue + entry = json.loads(line, fallback=None) + if not isinstance(entry, dict): + continue + if entry.get("type", "") in STRIPPABLE_TYPES and not entry.get( + "isCompactSummary" + ): + continue + msg = entry.get("message", {}) + role = msg.get("role", "") + if not role: + continue + msg_dict: dict = {"role": role} + raw_content = msg.get("content") + if role == "assistant" and isinstance(raw_content, list): + msg_dict["content"] = _flatten_assistant_content(raw_content) + elif isinstance(raw_content, list): + msg_dict["content"] = _flatten_tool_result_content(raw_content) + else: + msg_dict["content"] = raw_content or "" + messages.append(msg_dict) + return messages + + +def _messages_to_transcript(messages: list[dict]) -> str: + """Convert compressed message dicts back to JSONL transcript format. + + Rebuilds a minimal JSONL transcript from the ``{"role", "content"}`` + dicts returned by ``compress_context``. Each message becomes one JSONL + line with a fresh ``uuid`` / ``parentUuid`` chain so the CLI's + ``--resume`` flag can reconstruct a valid conversation tree. + + Assistant messages are wrapped in the full ``message`` envelope + (``id``, ``model``, ``stop_reason``, structured ``content`` blocks) + that the CLI expects. User messages use the simpler ``{role, content}`` + form. + + Returns: + A newline-terminated JSONL string, or an empty string if *messages* + is empty. + """ + lines: list[str] = [] + last_uuid: str = "" # root entry uses empty string, not null + for msg in messages: + role = msg.get("role", "user") + entry_type = "assistant" if role == "assistant" else "user" + uid = str(uuid4()) + content = msg.get("content", "") + if role == "assistant": + message: dict = { + "role": "assistant", + "model": "", + "id": f"{COMPACT_MSG_ID_PREFIX}{uuid4().hex[:24]}", + "type": ENTRY_TYPE_MESSAGE, + "content": [{"type": "text", "text": content}] if content else [], + "stop_reason": STOP_REASON_END_TURN, + "stop_sequence": None, + } + else: + message = {"role": role, "content": content} + entry = { + "type": entry_type, + "uuid": uid, + "parentUuid": last_uuid, + "message": message, + } + lines.append(json.dumps(entry, separators=(",", ":"))) + last_uuid = uid + return "\n".join(lines) + "\n" if lines else "" + + +_COMPACTION_TIMEOUT_SECONDS = 60 +_TRUNCATION_TIMEOUT_SECONDS = 30 + + +async def _run_compression( + messages: list[dict], + model: str, + log_prefix: str, +) -> CompressResult: + """Run LLM-based compression with truncation fallback. + + Uses the shared OpenAI client from ``get_openai_client()``. + If no client is configured or the LLM call fails, falls back to + truncation-based compression which drops older messages without + summarization. + + A 60-second timeout prevents a hung LLM call from blocking the + retry path indefinitely. The truncation fallback also has a + 30-second timeout to guard against slow tokenization on very large + transcripts. + """ + client = get_openai_client() + if client is None: + logger.warning("%s No OpenAI client configured, using truncation", log_prefix) + return await asyncio.wait_for( + compress_context(messages=messages, model=model, client=None), + timeout=_TRUNCATION_TIMEOUT_SECONDS, + ) + try: + return await asyncio.wait_for( + compress_context(messages=messages, model=model, client=client), + timeout=_COMPACTION_TIMEOUT_SECONDS, + ) + except Exception as e: + logger.warning("%s LLM compaction failed, using truncation: %s", log_prefix, e) + return await asyncio.wait_for( + compress_context(messages=messages, model=model, client=None), + timeout=_TRUNCATION_TIMEOUT_SECONDS, + ) + + +def _find_last_assistant_entry( + content: str, +) -> tuple[list[str], list[str]]: + """Split JSONL lines into (compressible_prefix, preserved_tail). + + The tail starts at the **first** entry of the last assistant turn and + includes everything after it (typically trailing user messages). An + assistant turn can span multiple consecutive JSONL entries sharing the + same ``message.id`` (e.g., a thinking entry followed by a tool_use + entry). All entries of the turn are preserved verbatim. + + The Anthropic API requires that ``thinking`` and ``redacted_thinking`` + blocks in the **last** assistant message remain value-identical to the + original response (the API validates parsed signature values, not raw + JSON bytes). By excluding the entire turn from compression we + guarantee those blocks are never altered. + + Returns ``(all_lines, [])`` when no assistant entry is found. + """ + lines = [ln for ln in content.strip().split("\n") if ln.strip()] + + # Parse all lines once to avoid double JSON deserialization. + # json.loads with fallback=None returns Any; non-dict entries are + # safely skipped by the isinstance(entry, dict) guards below. + parsed: list = [json.loads(ln, fallback=None) for ln in lines] + + # Reverse scan: find the message.id and index of the last assistant entry. + last_asst_msg_id: str | None = None + last_asst_idx: int | None = None + for i in range(len(parsed) - 1, -1, -1): + entry = parsed[i] + if not isinstance(entry, dict): + continue + msg = entry.get("message", {}) + if msg.get("role") == "assistant": + last_asst_idx = i + last_asst_msg_id = msg.get("id") + break + + if last_asst_idx is None: + return lines, [] + + # If the assistant entry has no message.id, fall back to preserving + # from that single entry onward — safer than compressing everything. + if last_asst_msg_id is None: + return lines[:last_asst_idx], lines[last_asst_idx:] + + # Forward scan: find the first entry of this turn (same message.id). + first_turn_idx: int | None = None + for i, entry in enumerate(parsed): + if not isinstance(entry, dict): + continue + msg = entry.get("message", {}) + if msg.get("role") == "assistant" and msg.get("id") == last_asst_msg_id: + first_turn_idx = i + break + + if first_turn_idx is None: + return lines, [] + return lines[:first_turn_idx], lines[first_turn_idx:] + + +async def compact_transcript( + content: str, + *, + model: str, + log_prefix: str = "[Transcript]", +) -> str | None: + """Compact an oversized JSONL transcript using LLM summarization. + + Converts transcript entries to plain messages, runs ``compress_context`` + (the same compressor used for pre-query history), and rebuilds JSONL. + + The **last assistant entry** (and any entries after it) are preserved + verbatim — never flattened or compressed. The Anthropic API requires + ``thinking`` and ``redacted_thinking`` blocks in the latest assistant + message to be value-identical to the original response (the API + validates parsed signature values, not raw JSON bytes); compressing + them would destroy the cryptographic signatures and cause + ``invalid_request_error``. + + Structured content in *older* assistant entries (``tool_use`` blocks, + ``thinking`` blocks, ``tool_result`` nesting, images) is flattened to + plain text for compression. This matches the fidelity of the Plan C + (DB compression) fallback path. + + Returns the compacted JSONL string, or ``None`` on failure. + + See also: + ``_compress_messages`` in ``service.py`` — compresses ``ChatMessage`` + lists for pre-query DB history. + """ + prefix_lines, tail_lines = _find_last_assistant_entry(content) + + # Build the JSONL string for the compressible prefix + prefix_content = "\n".join(prefix_lines) + "\n" if prefix_lines else "" + messages = _transcript_to_messages(prefix_content) if prefix_content else [] + + if len(messages) + len(tail_lines) < 2: + total = len(messages) + len(tail_lines) + logger.warning("%s Too few messages to compact (%d)", log_prefix, total) + return None + if not messages: + logger.warning("%s Nothing to compress (only tail entries remain)", log_prefix) + return None + try: + result = await _run_compression(messages, model, log_prefix) + if not result.was_compacted: + logger.warning( + "%s Compressor reports within budget but SDK rejected — " + "signalling failure", + log_prefix, + ) + return None + if not result.messages: + logger.warning("%s Compressor returned empty messages", log_prefix) + return None + logger.info( + "%s Compacted transcript: %d->%d tokens (%d summarized, %d dropped)", + log_prefix, + result.original_token_count, + result.token_count, + result.messages_summarized, + result.messages_dropped, + ) + compressed_part = _messages_to_transcript(result.messages) + + # Re-append the preserved tail (last assistant + trailing entries) + # with parentUuid patched to chain onto the compressed prefix. + tail_part = _rechain_tail(compressed_part, tail_lines) + compacted = compressed_part + tail_part + + if len(compacted) >= len(content): + # Byte count can increase due to preserved tail entries + # (thinking blocks, JSON overhead) even when token count + # decreased. Log a warning but still return — the API + # validates tokens not bytes, and the caller falls through + # to DB fallback if the transcript is still too large. + logger.warning( + "%s Compacted transcript (%d bytes) is not smaller than " + "original (%d bytes) — may still reduce token count", + log_prefix, + len(compacted), + len(content), + ) + # Authoritative validation — the caller (_reduce_context) also + # validates, but this is the canonical check that guarantees we + # never return a malformed transcript from this function. + if not validate_transcript(compacted): + logger.warning("%s Compacted transcript failed validation", log_prefix) + return None + return compacted + except Exception as e: + logger.error( + "%s Transcript compaction failed: %s", log_prefix, e, exc_info=True + ) + return None + + +def _rechain_tail(compressed_prefix: str, tail_lines: list[str]) -> str: + """Patch tail entries so their parentUuid chain links to the compressed prefix. + + The first tail entry's ``parentUuid`` is set to the ``uuid`` of the + last entry in the compressed prefix. Subsequent tail entries are + rechained to point to their predecessor in the tail — their original + ``parentUuid`` values may reference entries that were compressed away. + """ + if not tail_lines: + return "" + # Find the last uuid in the compressed prefix + last_prefix_uuid = "" + for line in reversed(compressed_prefix.strip().split("\n")): + if not line.strip(): + continue + entry = json.loads(line, fallback=None) + if isinstance(entry, dict) and "uuid" in entry: + last_prefix_uuid = entry["uuid"] + break + + result_lines: list[str] = [] + prev_uuid: str | None = None + for i, line in enumerate(tail_lines): + entry = json.loads(line, fallback=None) + if not isinstance(entry, dict): + # Safety guard: _find_last_assistant_entry already filters empty + # lines, and well-formed JSONL always parses to dicts. Non-dict + # lines are passed through unchanged; prev_uuid is intentionally + # NOT updated so the next dict entry chains to the last known uuid. + result_lines.append(line) + continue + if i == 0: + entry["parentUuid"] = last_prefix_uuid + elif prev_uuid is not None: + entry["parentUuid"] = prev_uuid + prev_uuid = entry.get("uuid") + result_lines.append(json.dumps(entry, separators=(",", ":"))) + return "\n".join(result_lines) + "\n" diff --git a/autogpt_platform/backend/backend/copilot/transcript_builder.py b/autogpt_platform/backend/backend/copilot/transcript_builder.py new file mode 100644 index 0000000000..b5f086f802 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/transcript_builder.py @@ -0,0 +1,240 @@ +"""Build complete JSONL transcript from SDK messages. + +The transcript represents the FULL active context at any point in time. +Each upload REPLACES the previous transcript atomically. + +Flow: + Turn 1: Upload [msg1, msg2] + Turn 2: Download [msg1, msg2] → Upload [msg1, msg2, msg3, msg4] (REPLACE) + Turn 3: Download [msg1, msg2, msg3, msg4] → Upload [all messages] (REPLACE) + +The transcript is never incremental - always the complete atomic state. +""" + +import logging +from typing import Any +from uuid import uuid4 + +from pydantic import BaseModel + +from backend.util import json + +from .transcript import STRIPPABLE_TYPES + +logger = logging.getLogger(__name__) + + +class TranscriptEntry(BaseModel): + """Single transcript entry (user or assistant turn).""" + + type: str + uuid: str + parentUuid: str = "" + isCompactSummary: bool | None = None + message: dict[str, Any] + + +class TranscriptBuilder: + """Build complete JSONL transcript from SDK messages. + + This builder maintains the FULL conversation state, not incremental changes. + The output is always the complete active context. + """ + + def __init__(self) -> None: + self._entries: list[TranscriptEntry] = [] + self._last_uuid: str | None = None + + def _last_is_assistant(self) -> bool: + return bool(self._entries) and self._entries[-1].type == "assistant" + + def _last_message_id(self) -> str: + """Return the message.id of the last entry, or '' if none.""" + if self._entries: + return self._entries[-1].message.get("id", "") + return "" + + @staticmethod + def _parse_entry(data: dict) -> TranscriptEntry | None: + """Parse a single transcript entry, filtering strippable types. + + Returns ``None`` for entries that should be skipped (strippable types + that are not compaction summaries). + """ + entry_type = data.get("type", "") + if entry_type in STRIPPABLE_TYPES and not data.get("isCompactSummary"): + return None + return TranscriptEntry( + type=entry_type, + uuid=data.get("uuid") or str(uuid4()), + parentUuid=data.get("parentUuid") or "", + isCompactSummary=data.get("isCompactSummary"), + message=data.get("message", {}), + ) + + def load_previous(self, content: str, log_prefix: str = "[Transcript]") -> None: + """Load complete previous transcript. + + This loads the FULL previous context. As new messages come in, + we append to this state. The final output is the complete context + (previous + new), not just the delta. + """ + if not content or not content.strip(): + return + + lines = content.strip().split("\n") + for line_num, line in enumerate(lines, 1): + if not line.strip(): + continue + + data = json.loads(line, fallback=None) + if data is None: + logger.warning( + "%s Failed to parse transcript line %d/%d", + log_prefix, + line_num, + len(lines), + ) + continue + + entry = self._parse_entry(data) + if entry is None: + continue + self._entries.append(entry) + self._last_uuid = entry.uuid + + logger.info( + "%s Loaded %d entries from previous transcript (last_uuid=%s)", + log_prefix, + len(self._entries), + self._last_uuid[:12] if self._last_uuid else None, + ) + + def append_user(self, content: str | list[dict], uuid: str | None = None) -> None: + """Append a user entry.""" + msg_uuid = uuid or str(uuid4()) + + self._entries.append( + TranscriptEntry( + type="user", + uuid=msg_uuid, + parentUuid=self._last_uuid or "", + message={"role": "user", "content": content}, + ) + ) + self._last_uuid = msg_uuid + + def append_tool_result(self, tool_use_id: str, content: str) -> None: + """Append a tool result as a user entry (one per tool call).""" + self.append_user( + content=[ + {"type": "tool_result", "tool_use_id": tool_use_id, "content": content} + ] + ) + + def append_assistant( + self, + content_blocks: list[dict], + model: str = "", + stop_reason: str | None = None, + ) -> None: + """Append an assistant entry. + + Consecutive assistant entries automatically share the same message ID + so the CLI can merge them (thinking → text → tool_use) into a single + API message on ``--resume``. A new ID is assigned whenever an + assistant entry follows a non-assistant entry (user message or tool + result), because that marks the start of a new API response. + """ + message_id = ( + self._last_message_id() + if self._last_is_assistant() + else f"msg_sdk_{uuid4().hex[:24]}" + ) + + msg_uuid = str(uuid4()) + + self._entries.append( + TranscriptEntry( + type="assistant", + uuid=msg_uuid, + parentUuid=self._last_uuid or "", + message={ + "role": "assistant", + "model": model, + "id": message_id, + "type": "message", + "content": content_blocks, + "stop_reason": stop_reason, + "stop_sequence": None, + }, + ) + ) + self._last_uuid = msg_uuid + + def replace_entries( + self, compacted_entries: list[dict], log_prefix: str = "[Transcript]" + ) -> None: + """Replace all entries with compacted entries from the CLI session file. + + Called after mid-stream compaction so TranscriptBuilder mirrors the + CLI's active context (compaction summary + post-compaction entries). + + Builds the new list first and validates it's non-empty before swapping, + so corrupt input cannot wipe the conversation history. + """ + new_entries: list[TranscriptEntry] = [] + for data in compacted_entries: + entry = self._parse_entry(data) + if entry is not None: + new_entries.append(entry) + + if not new_entries: + logger.warning( + "%s replace_entries produced 0 entries from %d inputs, keeping old (%d entries)", + log_prefix, + len(compacted_entries), + len(self._entries), + ) + return + + old_count = len(self._entries) + self._entries = new_entries + self._last_uuid = new_entries[-1].uuid + + logger.info( + "%s TranscriptBuilder compacted: %d entries -> %d entries", + log_prefix, + old_count, + len(self._entries), + ) + + def to_jsonl(self) -> str: + """Export complete context as JSONL. + + Consecutive assistant entries are kept separate to match the + native CLI format — the SDK merges them internally on resume. + + Returns the FULL conversation state (all entries), not incremental. + This output REPLACES any previous transcript. + """ + if not self._entries: + return "" + + lines = [entry.model_dump_json(exclude_none=True) for entry in self._entries] + return "\n".join(lines) + "\n" + + @property + def entry_count(self) -> int: + """Total number of entries in the complete context.""" + return len(self._entries) + + @property + def is_empty(self) -> bool: + """Whether this builder has any entries.""" + return len(self._entries) == 0 + + @property + def last_entry_type(self) -> str | None: + """Type of the last entry, or None if empty.""" + return self._entries[-1].type if self._entries else None diff --git a/autogpt_platform/backend/backend/copilot/transcript_builder_test.py b/autogpt_platform/backend/backend/copilot/transcript_builder_test.py new file mode 100644 index 0000000000..c53bbc29a0 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/transcript_builder_test.py @@ -0,0 +1,260 @@ +"""Tests for canonical TranscriptBuilder (backend.copilot.transcript_builder). + +These tests directly import from the canonical module to ensure codecov +patch coverage for the new file. +""" + +from backend.copilot.transcript_builder import TranscriptBuilder, TranscriptEntry +from backend.util import json + + +def _make_jsonl(*entries: dict) -> str: + return "\n".join(json.dumps(e) for e in entries) + "\n" + + +USER_MSG = { + "type": "user", + "uuid": "u1", + "message": {"role": "user", "content": "hello"}, +} +ASST_MSG = { + "type": "assistant", + "uuid": "a1", + "parentUuid": "u1", + "message": { + "role": "assistant", + "id": "msg_1", + "type": "message", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "stop_sequence": None, + }, +} + + +class TestTranscriptEntry: + def test_basic_construction(self): + entry = TranscriptEntry( + type="user", uuid="u1", message={"role": "user", "content": "hi"} + ) + assert entry.type == "user" + assert entry.uuid == "u1" + assert entry.parentUuid == "" + assert entry.isCompactSummary is None + + def test_optional_fields(self): + entry = TranscriptEntry( + type="summary", + uuid="s1", + parentUuid="p1", + isCompactSummary=True, + message={"role": "user", "content": "summary"}, + ) + assert entry.isCompactSummary is True + assert entry.parentUuid == "p1" + + +class TestTranscriptBuilderInit: + def test_starts_empty(self): + builder = TranscriptBuilder() + assert builder.is_empty + assert builder.entry_count == 0 + assert builder.last_entry_type is None + assert builder.to_jsonl() == "" + + +class TestAppendUser: + def test_appends_user_entry(self): + builder = TranscriptBuilder() + builder.append_user("hello") + assert builder.entry_count == 1 + assert builder.last_entry_type == "user" + + def test_chains_parent_uuid(self): + builder = TranscriptBuilder() + builder.append_user("first", uuid="u1") + builder.append_user("second", uuid="u2") + output = builder.to_jsonl() + entries = [json.loads(line) for line in output.strip().split("\n")] + assert entries[0]["parentUuid"] == "" + assert entries[1]["parentUuid"] == "u1" + + def test_custom_uuid(self): + builder = TranscriptBuilder() + builder.append_user("hello", uuid="custom-id") + output = builder.to_jsonl() + entry = json.loads(output.strip()) + assert entry["uuid"] == "custom-id" + + +class TestAppendToolResult: + def test_appends_as_user_entry(self): + builder = TranscriptBuilder() + builder.append_tool_result(tool_use_id="tc_1", content="result text") + assert builder.entry_count == 1 + assert builder.last_entry_type == "user" + output = builder.to_jsonl() + entry = json.loads(output.strip()) + content = entry["message"]["content"] + assert len(content) == 1 + assert content[0]["type"] == "tool_result" + assert content[0]["tool_use_id"] == "tc_1" + assert content[0]["content"] == "result text" + + +class TestAppendAssistant: + def test_appends_assistant_entry(self): + builder = TranscriptBuilder() + builder.append_user("hi") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "hello"}], + model="test-model", + stop_reason="end_turn", + ) + assert builder.entry_count == 2 + assert builder.last_entry_type == "assistant" + + def test_consecutive_assistants_share_message_id(self): + builder = TranscriptBuilder() + builder.append_user("hi") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "part 1"}], + model="m", + ) + builder.append_assistant( + content_blocks=[{"type": "text", "text": "part 2"}], + model="m", + ) + output = builder.to_jsonl() + entries = [json.loads(line) for line in output.strip().split("\n")] + # The two assistant entries share the same message ID + assert entries[1]["message"]["id"] == entries[2]["message"]["id"] + + def test_non_consecutive_assistants_get_different_ids(self): + builder = TranscriptBuilder() + builder.append_user("q1") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "a1"}], + model="m", + ) + builder.append_user("q2") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "a2"}], + model="m", + ) + output = builder.to_jsonl() + entries = [json.loads(line) for line in output.strip().split("\n")] + assert entries[1]["message"]["id"] != entries[3]["message"]["id"] + + +class TestLoadPrevious: + def test_loads_valid_entries(self): + content = _make_jsonl(USER_MSG, ASST_MSG) + builder = TranscriptBuilder() + builder.load_previous(content) + assert builder.entry_count == 2 + + def test_skips_empty_content(self): + builder = TranscriptBuilder() + builder.load_previous("") + assert builder.is_empty + builder.load_previous(" ") + assert builder.is_empty + + def test_skips_strippable_types(self): + progress = {"type": "progress", "uuid": "p1", "message": {}} + content = _make_jsonl(USER_MSG, progress, ASST_MSG) + builder = TranscriptBuilder() + builder.load_previous(content) + assert builder.entry_count == 2 # progress was skipped + + def test_preserves_compact_summary(self): + compact = { + "type": "summary", + "uuid": "cs1", + "isCompactSummary": True, + "message": {"role": "user", "content": "summary"}, + } + content = _make_jsonl(compact, ASST_MSG) + builder = TranscriptBuilder() + builder.load_previous(content) + assert builder.entry_count == 2 + + def test_skips_invalid_json_lines(self): + content = '{"type":"user","uuid":"u1","message":{}}\nnot-valid-json\n' + builder = TranscriptBuilder() + builder.load_previous(content) + assert builder.entry_count == 1 + + +class TestToJsonl: + def test_roundtrip(self): + builder = TranscriptBuilder() + builder.append_user("hello", uuid="u1") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "world"}], + model="m", + ) + output = builder.to_jsonl() + assert output.endswith("\n") + lines = output.strip().split("\n") + assert len(lines) == 2 + for line in lines: + parsed = json.loads(line) + assert "type" in parsed + assert "uuid" in parsed + assert "message" in parsed + + +class TestReplaceEntries: + def test_replaces_all_entries(self): + builder = TranscriptBuilder() + builder.append_user("old") + builder.append_assistant( + content_blocks=[{"type": "text", "text": "old answer"}], model="m" + ) + assert builder.entry_count == 2 + + compacted = [ + { + "type": "summary", + "uuid": "cs1", + "isCompactSummary": True, + "message": {"role": "user", "content": "compacted"}, + } + ] + builder.replace_entries(compacted) + assert builder.entry_count == 1 + + def test_empty_replacement_keeps_existing(self): + builder = TranscriptBuilder() + builder.append_user("keep me") + builder.replace_entries([]) + assert builder.entry_count == 1 + + +class TestParseEntry: + def test_filters_strippable_non_compact(self): + result = TranscriptBuilder._parse_entry( + {"type": "progress", "uuid": "p1", "message": {}} + ) + assert result is None + + def test_keeps_compact_summary(self): + result = TranscriptBuilder._parse_entry( + { + "type": "summary", + "uuid": "cs1", + "isCompactSummary": True, + "message": {}, + } + ) + assert result is not None + assert result.isCompactSummary is True + + def test_generates_uuid_if_missing(self): + result = TranscriptBuilder._parse_entry( + {"type": "user", "message": {"role": "user", "content": "hi"}} + ) + assert result is not None + assert result.uuid # Should be a generated UUID diff --git a/autogpt_platform/backend/backend/copilot/transcript_test.py b/autogpt_platform/backend/backend/copilot/transcript_test.py new file mode 100644 index 0000000000..dd99fd5a85 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/transcript_test.py @@ -0,0 +1,726 @@ +"""Tests for canonical transcript module (backend.copilot.transcript). + +Covers pure helper functions that are not exercised by the SDK re-export tests. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from backend.util import json + +from .transcript import ( + TranscriptDownload, + _build_path_from_parts, + _find_last_assistant_entry, + _flatten_assistant_content, + _flatten_tool_result_content, + _messages_to_transcript, + _meta_storage_path_parts, + _rechain_tail, + _sanitize_id, + _storage_path_parts, + _transcript_to_messages, + strip_for_upload, + validate_transcript, +) + + +def _make_jsonl(*entries: dict) -> str: + return "\n".join(json.dumps(e) for e in entries) + "\n" + + +# --------------------------------------------------------------------------- +# _sanitize_id +# --------------------------------------------------------------------------- + + +class TestSanitizeId: + def test_uuid_passes_through(self): + assert _sanitize_id("abcdef12-3456-7890-abcd-ef1234567890") == ( + "abcdef12-3456-7890-abcd-ef1234567890" + ) + + def test_strips_non_hex_characters(self): + # Only hex chars (0-9, a-f, A-F) and hyphens are kept + result = _sanitize_id("abc/../../etc/passwd") + assert "/" not in result + assert "." not in result + # 'p', 's', 'w' are not hex chars, so they are stripped + assert all(c in "0123456789abcdefABCDEF-" for c in result) + + def test_truncates_to_max_len(self): + long_id = "a" * 100 + result = _sanitize_id(long_id, max_len=10) + assert len(result) == 10 + + def test_empty_returns_unknown(self): + assert _sanitize_id("") == "unknown" + + def test_none_returns_unknown(self): + assert _sanitize_id(None) == "unknown" # type: ignore[arg-type] + + def test_special_chars_only_returns_unknown(self): + assert _sanitize_id("!@#$%^&*()") == "unknown" + + +# --------------------------------------------------------------------------- +# _storage_path_parts / _meta_storage_path_parts +# --------------------------------------------------------------------------- + + +class TestStoragePathParts: + def test_returns_triple(self): + prefix, uid, fname = _storage_path_parts("user-1", "sess-2") + assert prefix == "chat-transcripts" + assert "e" in uid # hex chars from "user-1" sanitized + assert fname.endswith(".jsonl") + + def test_meta_returns_meta_json(self): + prefix, uid, fname = _meta_storage_path_parts("user-1", "sess-2") + assert prefix == "chat-transcripts" + assert fname.endswith(".meta.json") + + +# --------------------------------------------------------------------------- +# _build_path_from_parts +# --------------------------------------------------------------------------- + + +class TestBuildPathFromParts: + def test_gcs_backend(self): + from backend.util.workspace_storage import GCSWorkspaceStorage + + mock_gcs = MagicMock(spec=GCSWorkspaceStorage) + mock_gcs.bucket_name = "my-bucket" + path = _build_path_from_parts(("wid", "fid", "file.jsonl"), mock_gcs) + assert path == "gcs://my-bucket/workspaces/wid/fid/file.jsonl" + + def test_local_backend(self): + # Use a plain object (not MagicMock) so isinstance(GCSWorkspaceStorage) is False + local_backend = type("LocalBackend", (), {})() + path = _build_path_from_parts(("wid", "fid", "file.jsonl"), local_backend) + assert path == "local://wid/fid/file.jsonl" + + +# --------------------------------------------------------------------------- +# TranscriptDownload dataclass +# --------------------------------------------------------------------------- + + +class TestTranscriptDownload: + def test_defaults(self): + td = TranscriptDownload(content="hello") + assert td.content == "hello" + assert td.message_count == 0 + assert td.uploaded_at == 0.0 + + def test_custom_values(self): + td = TranscriptDownload(content="data", message_count=5, uploaded_at=123.45) + assert td.message_count == 5 + assert td.uploaded_at == 123.45 + + +# --------------------------------------------------------------------------- +# _flatten_assistant_content +# --------------------------------------------------------------------------- + + +class TestFlattenAssistantContent: + def test_text_blocks(self): + blocks = [ + {"type": "text", "text": "Hello"}, + {"type": "text", "text": "World"}, + ] + assert _flatten_assistant_content(blocks) == "Hello\nWorld" + + def test_thinking_blocks_stripped(self): + blocks = [ + {"type": "thinking", "thinking": "hmm..."}, + {"type": "text", "text": "answer"}, + {"type": "redacted_thinking", "data": "secret"}, + ] + assert _flatten_assistant_content(blocks) == "answer" + + def test_tool_use_blocks_stripped(self): + blocks = [ + {"type": "text", "text": "I'll run a tool"}, + {"type": "tool_use", "name": "bash", "id": "tc1", "input": {}}, + ] + assert _flatten_assistant_content(blocks) == "I'll run a tool" + + def test_string_blocks(self): + blocks = ["hello", "world"] + assert _flatten_assistant_content(blocks) == "hello\nworld" + + def test_empty_blocks(self): + assert _flatten_assistant_content([]) == "" + + def test_unknown_dict_blocks_skipped(self): + blocks = [{"type": "image", "data": "base64..."}] + assert _flatten_assistant_content(blocks) == "" + + +# --------------------------------------------------------------------------- +# _flatten_tool_result_content +# --------------------------------------------------------------------------- + + +class TestFlattenToolResultContent: + def test_tool_result_with_text_content(self): + blocks = [ + { + "type": "tool_result", + "tool_use_id": "tc1", + "content": [{"type": "text", "text": "output data"}], + } + ] + assert _flatten_tool_result_content(blocks) == "output data" + + def test_tool_result_with_string_content(self): + blocks = [ + {"type": "tool_result", "tool_use_id": "tc1", "content": "simple string"} + ] + assert _flatten_tool_result_content(blocks) == "simple string" + + def test_tool_result_with_image_placeholder(self): + blocks = [ + { + "type": "tool_result", + "tool_use_id": "tc1", + "content": [{"type": "image", "data": "base64..."}], + } + ] + assert _flatten_tool_result_content(blocks) == "[__image__]" + + def test_tool_result_with_document_placeholder(self): + blocks = [ + { + "type": "tool_result", + "tool_use_id": "tc1", + "content": [{"type": "document", "data": "base64..."}], + } + ] + assert _flatten_tool_result_content(blocks) == "[__document__]" + + def test_tool_result_with_none_content(self): + blocks = [{"type": "tool_result", "tool_use_id": "tc1", "content": None}] + assert _flatten_tool_result_content(blocks) == "" + + def test_text_block_outside_tool_result(self): + blocks = [{"type": "text", "text": "standalone"}] + assert _flatten_tool_result_content(blocks) == "standalone" + + def test_unknown_dict_block_placeholder(self): + blocks = [{"type": "custom_widget", "data": "x"}] + assert _flatten_tool_result_content(blocks) == "[__custom_widget__]" + + def test_string_blocks(self): + blocks = ["raw text"] + assert _flatten_tool_result_content(blocks) == "raw text" + + def test_empty_blocks(self): + assert _flatten_tool_result_content([]) == "" + + def test_mixed_content_in_tool_result(self): + blocks = [ + { + "type": "tool_result", + "tool_use_id": "tc1", + "content": [ + {"type": "text", "text": "line1"}, + {"type": "image", "data": "..."}, + "raw string", + ], + } + ] + result = _flatten_tool_result_content(blocks) + assert "line1" in result + assert "[__image__]" in result + assert "raw string" in result + + def test_tool_result_with_dict_without_text_key(self): + blocks = [ + { + "type": "tool_result", + "tool_use_id": "tc1", + "content": [{"count": 42}], + } + ] + result = _flatten_tool_result_content(blocks) + assert "42" in result + + def test_tool_result_content_list_with_list_content(self): + blocks = [ + { + "type": "tool_result", + "tool_use_id": "tc1", + "content": [{"type": "text", "text": None}], + } + ] + result = _flatten_tool_result_content(blocks) + assert result == "None" + + +# --------------------------------------------------------------------------- +# _transcript_to_messages +# --------------------------------------------------------------------------- + +USER_ENTRY = { + "type": "user", + "uuid": "u1", + "parentUuid": "", + "message": {"role": "user", "content": "hello"}, +} +ASST_ENTRY = { + "type": "assistant", + "uuid": "a1", + "parentUuid": "u1", + "message": { + "role": "assistant", + "id": "msg_1", + "content": [{"type": "text", "text": "hi there"}], + }, +} +PROGRESS_ENTRY = { + "type": "progress", + "uuid": "p1", + "parentUuid": "u1", + "data": {}, +} + + +class TestTranscriptToMessages: + def test_basic_conversion(self): + content = _make_jsonl(USER_ENTRY, ASST_ENTRY) + messages = _transcript_to_messages(content) + assert len(messages) == 2 + assert messages[0] == {"role": "user", "content": "hello"} + assert messages[1]["role"] == "assistant" + assert messages[1]["content"] == "hi there" + + def test_skips_strippable_types(self): + content = _make_jsonl(USER_ENTRY, PROGRESS_ENTRY, ASST_ENTRY) + messages = _transcript_to_messages(content) + assert len(messages) == 2 + + def test_skips_entries_without_role(self): + no_role = {"type": "user", "uuid": "x", "message": {"content": "no role"}} + content = _make_jsonl(no_role) + messages = _transcript_to_messages(content) + assert len(messages) == 0 + + def test_handles_string_content(self): + entry = { + "type": "user", + "uuid": "u1", + "message": {"role": "user", "content": "plain string"}, + } + content = _make_jsonl(entry) + messages = _transcript_to_messages(content) + assert messages[0]["content"] == "plain string" + + def test_handles_tool_result_content(self): + entry = { + "type": "user", + "uuid": "u1", + "message": { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "tc1", "content": "output"} + ], + }, + } + content = _make_jsonl(entry) + messages = _transcript_to_messages(content) + assert messages[0]["content"] == "output" + + def test_handles_none_content(self): + entry = { + "type": "assistant", + "uuid": "a1", + "message": {"role": "assistant", "content": None}, + } + content = _make_jsonl(entry) + messages = _transcript_to_messages(content) + assert messages[0]["content"] == "" + + def test_skips_invalid_json(self): + content = "not valid json\n" + messages = _transcript_to_messages(content) + assert len(messages) == 0 + + def test_preserves_compact_summary(self): + compact = { + "type": "summary", + "uuid": "cs1", + "isCompactSummary": True, + "message": {"role": "user", "content": "summary of conversation"}, + } + content = _make_jsonl(compact) + messages = _transcript_to_messages(content) + assert len(messages) == 1 + + def test_strips_summary_without_compact_flag(self): + summary = { + "type": "summary", + "uuid": "s1", + "message": {"role": "user", "content": "summary"}, + } + content = _make_jsonl(summary) + messages = _transcript_to_messages(content) + assert len(messages) == 0 + + +# --------------------------------------------------------------------------- +# _messages_to_transcript +# --------------------------------------------------------------------------- + + +class TestMessagesToTranscript: + def test_basic_roundtrip(self): + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "world"}, + ] + result = _messages_to_transcript(messages) + assert result.endswith("\n") + lines = result.strip().split("\n") + assert len(lines) == 2 + + user_entry = json.loads(lines[0]) + assert user_entry["type"] == "user" + assert user_entry["message"]["role"] == "user" + assert user_entry["message"]["content"] == "hello" + assert user_entry["parentUuid"] == "" + + asst_entry = json.loads(lines[1]) + assert asst_entry["type"] == "assistant" + assert asst_entry["message"]["role"] == "assistant" + assert asst_entry["message"]["content"] == [{"type": "text", "text": "world"}] + assert asst_entry["parentUuid"] == user_entry["uuid"] + + def test_empty_messages(self): + assert _messages_to_transcript([]) == "" + + def test_assistant_has_message_envelope(self): + messages = [{"role": "assistant", "content": "test"}] + result = _messages_to_transcript(messages) + entry = json.loads(result.strip()) + msg = entry["message"] + assert "id" in msg + assert msg["id"].startswith("msg_compact_") + assert msg["type"] == "message" + assert msg["stop_reason"] == "end_turn" + assert msg["stop_sequence"] is None + + def test_uuid_chain(self): + messages = [ + {"role": "user", "content": "a"}, + {"role": "assistant", "content": "b"}, + {"role": "user", "content": "c"}, + ] + result = _messages_to_transcript(messages) + lines = result.strip().split("\n") + entries = [json.loads(line) for line in lines] + assert entries[0]["parentUuid"] == "" + assert entries[1]["parentUuid"] == entries[0]["uuid"] + assert entries[2]["parentUuid"] == entries[1]["uuid"] + + def test_assistant_with_empty_content(self): + messages = [{"role": "assistant", "content": ""}] + result = _messages_to_transcript(messages) + entry = json.loads(result.strip()) + assert entry["message"]["content"] == [] + + +# --------------------------------------------------------------------------- +# _find_last_assistant_entry +# --------------------------------------------------------------------------- + + +class TestFindLastAssistantEntry: + def test_splits_at_last_assistant(self): + user = { + "type": "user", + "uuid": "u1", + "message": {"role": "user", "content": "hi"}, + } + asst = { + "type": "assistant", + "uuid": "a1", + "message": {"role": "assistant", "id": "msg1", "content": "answer"}, + } + content = _make_jsonl(user, asst) + prefix, tail = _find_last_assistant_entry(content) + assert len(prefix) == 1 + assert len(tail) == 1 + + def test_no_assistant_returns_all_in_prefix(self): + user1 = { + "type": "user", + "uuid": "u1", + "message": {"role": "user", "content": "hi"}, + } + user2 = { + "type": "user", + "uuid": "u2", + "message": {"role": "user", "content": "hey"}, + } + content = _make_jsonl(user1, user2) + prefix, tail = _find_last_assistant_entry(content) + assert len(prefix) == 2 + assert len(tail) == 0 + + def test_multi_entry_turn_preserved(self): + user = { + "type": "user", + "uuid": "u1", + "message": {"role": "user", "content": "q"}, + } + asst1 = { + "type": "assistant", + "uuid": "a1", + "message": { + "role": "assistant", + "id": "msg_turn", + "content": [{"type": "thinking", "thinking": "hmm"}], + }, + } + asst2 = { + "type": "assistant", + "uuid": "a2", + "message": { + "role": "assistant", + "id": "msg_turn", + "content": [{"type": "text", "text": "answer"}], + }, + } + content = _make_jsonl(user, asst1, asst2) + prefix, tail = _find_last_assistant_entry(content) + assert len(prefix) == 1 # just the user + assert len(tail) == 2 # both assistant entries + + def test_assistant_without_id(self): + user = { + "type": "user", + "uuid": "u1", + "message": {"role": "user", "content": "q"}, + } + asst = { + "type": "assistant", + "uuid": "a1", + "message": {"role": "assistant", "content": "no id"}, + } + content = _make_jsonl(user, asst) + prefix, tail = _find_last_assistant_entry(content) + assert len(prefix) == 1 + assert len(tail) == 1 + + def test_trailing_user_after_assistant(self): + user1 = { + "type": "user", + "uuid": "u1", + "message": {"role": "user", "content": "q"}, + } + asst = { + "type": "assistant", + "uuid": "a1", + "message": {"role": "assistant", "id": "msg1", "content": "a"}, + } + user2 = { + "type": "user", + "uuid": "u2", + "message": {"role": "user", "content": "follow"}, + } + content = _make_jsonl(user1, asst, user2) + prefix, tail = _find_last_assistant_entry(content) + assert len(prefix) == 1 # user1 + assert len(tail) == 2 # asst + user2 + + +# --------------------------------------------------------------------------- +# _rechain_tail +# --------------------------------------------------------------------------- + + +class TestRechainTail: + def test_empty_tail(self): + assert _rechain_tail("some prefix\n", []) == "" + + def test_patches_first_entry_parent(self): + prefix_entry = {"uuid": "last-prefix-uuid", "type": "user", "message": {}} + prefix = json.dumps(prefix_entry) + "\n" + + tail_entry = { + "uuid": "t1", + "parentUuid": "old-parent", + "type": "assistant", + "message": {}, + } + tail_lines = [json.dumps(tail_entry)] + + result = _rechain_tail(prefix, tail_lines) + parsed = json.loads(result.strip()) + assert parsed["parentUuid"] == "last-prefix-uuid" + + def test_chains_consecutive_tail_entries(self): + prefix_entry = {"uuid": "p1", "type": "user", "message": {}} + prefix = json.dumps(prefix_entry) + "\n" + + t1 = {"uuid": "t1", "parentUuid": "old1", "type": "assistant", "message": {}} + t2 = {"uuid": "t2", "parentUuid": "old2", "type": "user", "message": {}} + tail_lines = [json.dumps(t1), json.dumps(t2)] + + result = _rechain_tail(prefix, tail_lines) + entries = [json.loads(line) for line in result.strip().split("\n")] + assert entries[0]["parentUuid"] == "p1" + assert entries[1]["parentUuid"] == "t1" + + def test_non_dict_lines_passed_through(self): + prefix_entry = {"uuid": "p1", "type": "user", "message": {}} + prefix = json.dumps(prefix_entry) + "\n" + + tail_lines = ["not-a-json-dict"] + result = _rechain_tail(prefix, tail_lines) + assert "not-a-json-dict" in result + + +# --------------------------------------------------------------------------- +# strip_for_upload (combined single-parse) +# --------------------------------------------------------------------------- + + +class TestStripForUpload: + def test_strips_progress_and_thinking(self): + user = { + "type": "user", + "uuid": "u1", + "parentUuid": "", + "message": {"role": "user", "content": "hi"}, + } + progress = {"type": "progress", "uuid": "p1", "parentUuid": "u1", "data": {}} + asst_old = { + "type": "assistant", + "uuid": "a1", + "parentUuid": "p1", + "message": { + "role": "assistant", + "id": "msg_old", + "content": [ + {"type": "thinking", "thinking": "stale thinking"}, + {"type": "text", "text": "old answer"}, + ], + }, + } + user2 = { + "type": "user", + "uuid": "u2", + "parentUuid": "a1", + "message": {"role": "user", "content": "next"}, + } + asst_new = { + "type": "assistant", + "uuid": "a2", + "parentUuid": "u2", + "message": { + "role": "assistant", + "id": "msg_new", + "content": [ + {"type": "thinking", "thinking": "fresh thinking"}, + {"type": "text", "text": "new answer"}, + ], + }, + } + content = _make_jsonl(user, progress, asst_old, user2, asst_new) + result = strip_for_upload(content) + + lines = result.strip().split("\n") + # Progress should be stripped -> 4 entries remain + assert len(lines) == 4 + + # First entry (user) should be reparented since its child (progress) was stripped + entries = [json.loads(line) for line in lines] + types = [e.get("type") for e in entries] + assert "progress" not in types + + # Old assistant thinking stripped, new assistant thinking preserved + old_asst = next( + e for e in entries if e.get("message", {}).get("id") == "msg_old" + ) + old_content = old_asst["message"]["content"] + old_types = [b["type"] for b in old_content if isinstance(b, dict)] + assert "thinking" not in old_types + assert "text" in old_types + + new_asst = next( + e for e in entries if e.get("message", {}).get("id") == "msg_new" + ) + new_content = new_asst["message"]["content"] + new_types = [b["type"] for b in new_content if isinstance(b, dict)] + assert "thinking" in new_types # last assistant preserved + + def test_empty_content(self): + result = strip_for_upload("") + # Empty string produces a single empty line after split, resulting in "\n" + assert result.strip() == "" + + def test_preserves_compact_summary(self): + compact = { + "type": "summary", + "uuid": "cs1", + "isCompactSummary": True, + "message": {"role": "user", "content": "summary"}, + } + asst = { + "type": "assistant", + "uuid": "a1", + "parentUuid": "cs1", + "message": {"role": "assistant", "id": "msg1", "content": "answer"}, + } + content = _make_jsonl(compact, asst) + result = strip_for_upload(content) + lines = result.strip().split("\n") + assert len(lines) == 2 + + def test_no_assistant_entries(self): + user = { + "type": "user", + "uuid": "u1", + "message": {"role": "user", "content": "hi"}, + } + content = _make_jsonl(user) + result = strip_for_upload(content) + lines = result.strip().split("\n") + assert len(lines) == 1 + + +# --------------------------------------------------------------------------- +# validate_transcript (additional edge cases) +# --------------------------------------------------------------------------- + + +class TestValidateTranscript: + def test_valid_with_assistant(self): + content = _make_jsonl( + USER_ENTRY, + ASST_ENTRY, + ) + assert validate_transcript(content) is True + + def test_none_returns_false(self): + assert validate_transcript(None) is False + + def test_whitespace_only_returns_false(self): + assert validate_transcript(" \n ") is False + + def test_no_assistant_returns_false(self): + content = _make_jsonl(USER_ENTRY) + assert validate_transcript(content) is False + + def test_invalid_json_returns_false(self): + assert validate_transcript("not json\n") is False + + def test_assistant_only_is_valid(self): + content = _make_jsonl(ASST_ENTRY) + assert validate_transcript(content) is True diff --git a/autogpt_platform/backend/backend/data/block_cost_config.py b/autogpt_platform/backend/backend/data/block_cost_config.py index f9e49efc95..1753d5e65e 100644 --- a/autogpt_platform/backend/backend/data/block_cost_config.py +++ b/autogpt_platform/backend/backend/data/block_cost_config.py @@ -147,6 +147,19 @@ MODEL_COST: dict[LlmModel, int] = { LlmModel.KIMI_K2: 1, LlmModel.QWEN3_235B_A22B_THINKING: 1, LlmModel.QWEN3_CODER: 9, + # Z.ai (Zhipu) models + LlmModel.ZAI_GLM_4_32B: 1, + LlmModel.ZAI_GLM_4_5: 2, + LlmModel.ZAI_GLM_4_5_AIR: 1, + LlmModel.ZAI_GLM_4_5_AIR_FREE: 1, + LlmModel.ZAI_GLM_4_5V: 2, + LlmModel.ZAI_GLM_4_6: 1, + LlmModel.ZAI_GLM_4_6V: 1, + LlmModel.ZAI_GLM_4_7: 1, + LlmModel.ZAI_GLM_4_7_FLASH: 1, + LlmModel.ZAI_GLM_5: 2, + LlmModel.ZAI_GLM_5_TURBO: 4, + LlmModel.ZAI_GLM_5V_TURBO: 4, # v0 by Vercel models LlmModel.V0_1_5_MD: 1, LlmModel.V0_1_5_LG: 2, diff --git a/autogpt_platform/backend/backend/util/feature_flag.py b/autogpt_platform/backend/backend/util/feature_flag.py index 2af9659011..47ad704fc3 100644 --- a/autogpt_platform/backend/backend/util/feature_flag.py +++ b/autogpt_platform/backend/backend/util/feature_flag.py @@ -1,5 +1,6 @@ import contextlib import logging +import os from enum import Enum from functools import wraps from typing import Any, Awaitable, Callable, TypeVar @@ -38,6 +39,7 @@ class Flag(str, Enum): AGENT_ACTIVITY = "agent-activity" ENABLE_PLATFORM_PAYMENT = "enable-platform-payment" CHAT = "chat" + CHAT_MODE_OPTION = "chat-mode-option" COPILOT_SDK = "copilot-sdk" COPILOT_DAILY_TOKEN_LIMIT = "copilot-daily-token-limit" COPILOT_WEEKLY_TOKEN_LIMIT = "copilot-weekly-token-limit" @@ -165,6 +167,30 @@ async def get_feature_flag_value( return default +def _env_flag_override(flag_key: Flag) -> bool | None: + """Return a local override for ``flag_key`` from the environment. + + Set ``FORCE_FLAG_=true|false`` (``NAME`` = flag value with + ``-`` → ``_``, upper-cased) to bypass LaunchDarkly for a single + flag in local dev or tests. Returns ``None`` when no override + is configured so the caller falls through to LaunchDarkly. + + The ``NEXT_PUBLIC_FORCE_FLAG_`` prefix is also accepted so a + single shared env var can toggle a flag across backend and + frontend (the frontend requires the ``NEXT_PUBLIC_`` prefix to + expose the value to the browser bundle). + + Example: ``FORCE_FLAG_CHAT_MODE_OPTION=true`` forces + ``Flag.CHAT_MODE_OPTION`` on regardless of LaunchDarkly. + """ + suffix = flag_key.value.upper().replace("-", "_") + for prefix in ("FORCE_FLAG_", "NEXT_PUBLIC_FORCE_FLAG_"): + raw = os.environ.get(prefix + suffix) + if raw is not None: + return raw.strip().lower() in ("1", "true", "yes", "on") + return None + + async def is_feature_enabled( flag_key: Flag, user_id: str, @@ -181,6 +207,11 @@ async def is_feature_enabled( Returns: True if feature is enabled, False otherwise """ + override = _env_flag_override(flag_key) + if override is not None: + logger.debug(f"Feature flag {flag_key} overridden by env: {override}") + return override + result = await get_feature_flag_value(flag_key.value, user_id, default) # If the result is already a boolean, return it diff --git a/autogpt_platform/backend/backend/util/feature_flag_test.py b/autogpt_platform/backend/backend/util/feature_flag_test.py index 9bd99809ff..9a11256ef8 100644 --- a/autogpt_platform/backend/backend/util/feature_flag_test.py +++ b/autogpt_platform/backend/backend/util/feature_flag_test.py @@ -4,6 +4,7 @@ from ldclient import LDClient from backend.util.feature_flag import ( Flag, + _env_flag_override, feature_flag, is_feature_enabled, mock_flag_variation, @@ -111,3 +112,59 @@ async def test_is_feature_enabled_with_flag_enum(mocker): assert result is True # Should call with the flag's string value mock_get_feature_flag_value.assert_called_once() + + +class TestEnvFlagOverride: + def test_force_flag_true(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FORCE_FLAG_CHAT", "true") + assert _env_flag_override(Flag.CHAT) is True + + def test_force_flag_false(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FORCE_FLAG_CHAT", "false") + assert _env_flag_override(Flag.CHAT) is False + + def test_next_public_prefix_true(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("NEXT_PUBLIC_FORCE_FLAG_CHAT", "true") + assert _env_flag_override(Flag.CHAT) is True + + def test_unset_returns_none(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("FORCE_FLAG_CHAT", raising=False) + monkeypatch.delenv("NEXT_PUBLIC_FORCE_FLAG_CHAT", raising=False) + assert _env_flag_override(Flag.CHAT) is None + + def test_invalid_value_returns_false(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FORCE_FLAG_CHAT", "notaboolean") + assert _env_flag_override(Flag.CHAT) is False + + def test_numeric_one_returns_true(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FORCE_FLAG_CHAT", "1") + assert _env_flag_override(Flag.CHAT) is True + + def test_yes_returns_true(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FORCE_FLAG_CHAT", "yes") + assert _env_flag_override(Flag.CHAT) is True + + def test_on_returns_true(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FORCE_FLAG_CHAT", "on") + assert _env_flag_override(Flag.CHAT) is True + + def test_hyphenated_flag_converts_to_underscore( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setenv("FORCE_FLAG_CHAT_MODE_OPTION", "true") + assert _env_flag_override(Flag.CHAT_MODE_OPTION) is True + + def test_force_flag_takes_precedence_over_next_public( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setenv("FORCE_FLAG_CHAT", "false") + monkeypatch.setenv("NEXT_PUBLIC_FORCE_FLAG_CHAT", "true") + assert _env_flag_override(Flag.CHAT) is False + + def test_whitespace_is_stripped(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FORCE_FLAG_CHAT", " true ") + assert _env_flag_override(Flag.CHAT) is True + + def test_case_insensitive_value(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FORCE_FLAG_CHAT", "TRUE") + assert _env_flag_override(Flag.CHAT) is True diff --git a/autogpt_platform/backend/backend/util/workspace.py b/autogpt_platform/backend/backend/util/workspace.py index 34ab1e3582..5ec4a5b336 100644 --- a/autogpt_platform/backend/backend/util/workspace.py +++ b/autogpt_platform/backend/backend/util/workspace.py @@ -155,6 +155,7 @@ class WorkspaceManager: path: Optional[str] = None, mime_type: Optional[str] = None, overwrite: bool = False, + metadata: Optional[dict] = None, ) -> WorkspaceFile: """ Write file to workspace. @@ -168,6 +169,7 @@ class WorkspaceManager: path: Virtual path (defaults to "/{filename}", session-scoped if session_id set) mime_type: MIME type (auto-detected if not provided) overwrite: Whether to overwrite existing file at path + metadata: Optional metadata dict (e.g., origin tracking) Returns: Created WorkspaceFile instance @@ -246,6 +248,7 @@ class WorkspaceManager: mime_type=mime_type, size_bytes=len(content), checksum=checksum, + metadata=metadata, ) except UniqueViolationError: if retries > 0: diff --git a/autogpt_platform/backend/test/agent_generator/test_orchestrator.py b/autogpt_platform/backend/test/agent_generator/test_orchestrator.py index 557db8016b..0096b222ef 100644 --- a/autogpt_platform/backend/test/agent_generator/test_orchestrator.py +++ b/autogpt_platform/backend/test/agent_generator/test_orchestrator.py @@ -140,7 +140,9 @@ class TestFixOrchestratorBlocks: assert defaults["conversation_compaction"] is True assert defaults["retry"] == 3 assert defaults["multiple_tool_calls"] is False - assert len(fixer.fixes_applied) == 4 + assert defaults["execution_mode"] == "extended_thinking" + assert defaults["model"] == "claude-opus-4-6" + assert len(fixer.fixes_applied) == 6 def test_preserves_existing_values(self): """Existing user-set values are never overwritten.""" @@ -153,6 +155,8 @@ class TestFixOrchestratorBlocks: "conversation_compaction": False, "retry": 1, "multiple_tool_calls": True, + "execution_mode": "built_in", + "model": "gpt-4o", } ) ], @@ -166,6 +170,8 @@ class TestFixOrchestratorBlocks: assert defaults["conversation_compaction"] is False assert defaults["retry"] == 1 assert defaults["multiple_tool_calls"] is True + assert defaults["execution_mode"] == "built_in" + assert defaults["model"] == "gpt-4o" assert len(fixer.fixes_applied) == 0 def test_partial_defaults(self): @@ -189,7 +195,9 @@ class TestFixOrchestratorBlocks: assert defaults["conversation_compaction"] is True # filled assert defaults["retry"] == 3 # filled assert defaults["multiple_tool_calls"] is False # filled - assert len(fixer.fixes_applied) == 3 + assert defaults["execution_mode"] == "extended_thinking" # filled + assert defaults["model"] == "claude-opus-4-6" # filled + assert len(fixer.fixes_applied) == 5 def test_skips_non_sdm_nodes(self): """Non-Orchestrator nodes are untouched.""" @@ -258,11 +266,13 @@ class TestFixOrchestratorBlocks: result = fixer.fix_orchestrator_blocks(agent) defaults = result["nodes"][0]["input_default"] - assert defaults["agent_mode_max_iterations"] == 10 # None → default - assert defaults["conversation_compaction"] is True # None → default + assert defaults["agent_mode_max_iterations"] == 10 # None -> default + assert defaults["conversation_compaction"] is True # None -> default assert defaults["retry"] == 3 # kept assert defaults["multiple_tool_calls"] is False # kept - assert len(fixer.fixes_applied) == 2 + assert defaults["execution_mode"] == "extended_thinking" # filled + assert defaults["model"] == "claude-opus-4-6" # filled + assert len(fixer.fixes_applied) == 4 def test_multiple_sdm_nodes(self): """Multiple SDM nodes are all fixed independently.""" @@ -277,11 +287,11 @@ class TestFixOrchestratorBlocks: result = fixer.fix_orchestrator_blocks(agent) - # First node: 3 defaults filled (agent_mode was already set) + # First node: 5 defaults filled (agent_mode was already set) assert result["nodes"][0]["input_default"]["agent_mode_max_iterations"] == 3 - # Second node: all 4 defaults filled + # Second node: all 6 defaults filled assert result["nodes"][1]["input_default"]["agent_mode_max_iterations"] == 10 - assert len(fixer.fixes_applied) == 7 # 3 + 4 + assert len(fixer.fixes_applied) == 11 # 5 + 6 def test_registered_in_apply_all_fixes(self): """fix_orchestrator_blocks runs as part of apply_all_fixes.""" @@ -655,6 +665,7 @@ class TestOrchestratorE2EPipeline: "conversation_compaction": {"type": "boolean"}, "retry": {"type": "integer"}, "multiple_tool_calls": {"type": "boolean"}, + "execution_mode": {"type": "string"}, }, "required": ["prompt"], }, diff --git a/autogpt_platform/backend/test/copilot/__init__.py b/autogpt_platform/backend/test/copilot/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/autogpt_platform/backend/test/copilot/dry_run_loop_test.py b/autogpt_platform/backend/test/copilot/dry_run_loop_test.py new file mode 100644 index 0000000000..b55a050fd2 --- /dev/null +++ b/autogpt_platform/backend/test/copilot/dry_run_loop_test.py @@ -0,0 +1,394 @@ +"""Prompt regression tests AND functional tests for the dry-run verification loop. + +NOTE: This file lives in test/copilot/ rather than being colocated with a +single source module because it is a cross-cutting test spanning multiple +modules: prompting.py, service.py, agent_generation_guide.md, and run_agent.py. + +These tests verify that the create -> dry-run -> fix iterative workflow is +properly communicated through tool descriptions, the prompting supplement, +and the agent building guide. + +After deduplication, the full dry-run workflow lives in the +agent_generation_guide.md only. The system prompt and individual tool +descriptions no longer repeat it — they keep a minimal footprint. + +**Intentionally brittle**: the assertions check for specific substrings so +that accidental removal or rewording of key instructions is caught. If you +deliberately reword a prompt, update the corresponding assertion here. + +--- Functional tests (added separately) --- + +The dry-run loop is primarily a *prompt/guide* feature — the copilot reads +the guide and follows its instructions. There are no standalone Python +functions that implement "loop until passing" logic; the loop is driven by +the LLM. However, several pieces of real Python infrastructure make the +loop possible: + +1. The ``run_agent`` and ``run_block`` OpenAI tool schemas expose a + ``dry_run`` boolean parameter that the LLM must be able to set. +2. The ``RunAgentInput`` Pydantic model validates ``dry_run`` as a required + bool, so the executor can branch on it. +3. The ``_check_prerequisites`` method in ``RunAgentTool`` bypasses + credential and missing-input gates when ``dry_run=True``. +4. The guide documents the workflow steps in a specific order that the LLM + must follow: create/edit -> dry-run -> inspect -> fix -> repeat. + +The functional test classes below exercise items 1-4 directly. +""" + +import re +from pathlib import Path +from typing import Any, cast + +import pytest +from openai.types.chat import ChatCompletionToolParam +from pydantic import ValidationError + +from backend.copilot.prompting import get_sdk_supplement +from backend.copilot.service import DEFAULT_SYSTEM_PROMPT +from backend.copilot.tools import TOOL_REGISTRY +from backend.copilot.tools.run_agent import RunAgentInput + +# Resolved once for the whole module so individual tests stay fast. +_SDK_SUPPLEMENT = get_sdk_supplement(use_e2b=False, cwd="/tmp/test") + + +# --------------------------------------------------------------------------- +# Prompt regression tests (original) +# --------------------------------------------------------------------------- + + +class TestSystemPromptBasics: + """Verify the system prompt includes essential baseline content. + + After deduplication, the dry-run workflow lives only in the guide. + The system prompt carries tone and personality only. + """ + + def test_mentions_automations(self): + assert "automations" in DEFAULT_SYSTEM_PROMPT.lower() + + def test_mentions_action_oriented(self): + assert "action-oriented" in DEFAULT_SYSTEM_PROMPT.lower() + + +class TestToolDescriptionsDryRunLoop: + """Verify tool descriptions and parameters related to the dry-run loop.""" + + def test_get_agent_building_guide_mentions_workflow(self): + desc = TOOL_REGISTRY["get_agent_building_guide"].description + assert "dry-run" in desc.lower() + + def test_run_agent_dry_run_param_exists_and_is_boolean(self): + schema = TOOL_REGISTRY["run_agent"].as_openai_tool() + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + assert "dry_run" in params["properties"] + assert params["properties"]["dry_run"]["type"] == "boolean" + + def test_run_agent_dry_run_param_mentions_simulation(self): + """After deduplication the dry_run param description mentions simulation.""" + schema = TOOL_REGISTRY["run_agent"].as_openai_tool() + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + dry_run_desc = params["properties"]["dry_run"]["description"] + assert "simulat" in dry_run_desc.lower() + + +class TestPromptingSupplementContent: + """Verify the prompting supplement (via get_sdk_supplement) includes + essential shared tool notes. After deduplication, the dry-run workflow + lives only in the guide; the supplement carries storage, file-handling, + and tool-discovery notes. + """ + + def test_includes_tool_discovery_priority(self): + assert "Tool Discovery Priority" in _SDK_SUPPLEMENT + + def test_includes_find_block_first(self): + assert "find_block first" in _SDK_SUPPLEMENT or "find_block" in _SDK_SUPPLEMENT + + def test_includes_send_authenticated_web_request(self): + assert "SendAuthenticatedWebRequestBlock" in _SDK_SUPPLEMENT + + +class TestAgentBuildingGuideDryRunLoop: + """Verify the agent building guide includes the dry-run loop.""" + + @pytest.fixture + def guide_content(self): + guide_path = ( + Path(__file__).resolve().parent.parent.parent + / "backend" + / "copilot" + / "sdk" + / "agent_generation_guide.md" + ) + return guide_path.read_text(encoding="utf-8") + + def test_has_dry_run_verification_section(self, guide_content): + assert "REQUIRED: Dry-Run Verification Loop" in guide_content + + def test_workflow_includes_dry_run_step(self, guide_content): + assert "dry_run=True" in guide_content + + def test_mentions_good_vs_bad_output(self, guide_content): + assert "**Good output**" in guide_content + assert "**Bad output**" in guide_content + + def test_mentions_repeat_until_pass(self, guide_content): + lower = guide_content.lower() + assert "repeat" in lower + assert "clearly unfixable" in lower + + def test_mentions_wait_for_result(self, guide_content): + assert "wait_for_result=120" in guide_content + + def test_mentions_view_agent_output(self, guide_content): + assert "view_agent_output" in guide_content + + def test_workflow_has_dry_run_and_inspect_steps(self, guide_content): + assert "**Dry-run**" in guide_content + assert "**Inspect & fix**" in guide_content + + +# --------------------------------------------------------------------------- +# Functional tests: tool schema validation +# --------------------------------------------------------------------------- + + +class TestRunAgentToolSchema: + """Validate the run_agent OpenAI tool schema exposes dry_run correctly. + + These go beyond substring checks — they verify the full schema structure + that the LLM receives, ensuring the parameter is well-formed and will be + parsed correctly by OpenAI function-calling. + """ + + @pytest.fixture + def schema(self) -> ChatCompletionToolParam: + return TOOL_REGISTRY["run_agent"].as_openai_tool() + + def test_schema_is_valid_openai_tool(self, schema: ChatCompletionToolParam): + """The schema has the required top-level OpenAI structure.""" + assert schema["type"] == "function" + assert "function" in schema + func = schema["function"] + assert "name" in func + assert "description" in func + assert "parameters" in func + assert func["name"] == "run_agent" + + def test_dry_run_is_required(self, schema: ChatCompletionToolParam): + """dry_run must be in 'required' so the LLM always provides it explicitly.""" + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + required = params.get("required", []) + assert "dry_run" in required + + def test_dry_run_is_boolean_type(self, schema: ChatCompletionToolParam): + """dry_run must be typed as boolean so the LLM generates true/false.""" + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + assert params["properties"]["dry_run"]["type"] == "boolean" + + def test_dry_run_description_is_nonempty(self, schema: ChatCompletionToolParam): + """The description must be present and substantive for LLM guidance.""" + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + desc = params["properties"]["dry_run"]["description"] + assert isinstance(desc, str) + assert len(desc) > 10, "Description too short to guide the LLM" + + def test_wait_for_result_coexists_with_dry_run( + self, schema: ChatCompletionToolParam + ): + """wait_for_result must also be present — the guide instructs the LLM + to pass both dry_run=True and wait_for_result=120 together.""" + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + assert "wait_for_result" in params["properties"] + assert params["properties"]["wait_for_result"]["type"] == "integer" + + +class TestRunBlockToolSchema: + """Validate the run_block OpenAI tool schema exposes dry_run correctly.""" + + @pytest.fixture + def schema(self) -> ChatCompletionToolParam: + return TOOL_REGISTRY["run_block"].as_openai_tool() + + def test_schema_is_valid_openai_tool(self, schema: ChatCompletionToolParam): + assert schema["type"] == "function" + func = schema["function"] + assert func["name"] == "run_block" + assert "parameters" in func + + def test_dry_run_exists_and_is_boolean(self, schema: ChatCompletionToolParam): + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + props = params["properties"] + assert "dry_run" in props + assert props["dry_run"]["type"] == "boolean" + + def test_dry_run_is_required(self, schema: ChatCompletionToolParam): + """dry_run must be required — along with block_id and input_data.""" + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + required = params.get("required", []) + assert "dry_run" in required + assert "block_id" in required + assert "input_data" in required + + def test_dry_run_description_mentions_preview( + self, schema: ChatCompletionToolParam + ): + params = cast(dict[str, Any], schema["function"].get("parameters", {})) + desc = params["properties"]["dry_run"]["description"] + assert isinstance(desc, str) + assert ( + "preview mode" in desc.lower() + ), "run_block dry_run description should mention preview mode" + + +# --------------------------------------------------------------------------- +# Functional tests: RunAgentInput Pydantic model +# --------------------------------------------------------------------------- + + +class TestRunAgentInputModel: + """Validate RunAgentInput Pydantic model handles dry_run correctly. + + The executor reads dry_run from this model, so it must parse, default, + and validate properly. + """ + + def test_dry_run_accepts_true(self): + model = RunAgentInput(username_agent_slug="user/agent", dry_run=True) + assert model.dry_run is True + + def test_dry_run_accepts_false(self): + """dry_run=False must be accepted when provided explicitly.""" + model = RunAgentInput(username_agent_slug="user/agent", dry_run=False) + assert model.dry_run is False + + def test_dry_run_coerces_truthy_int(self): + """Pydantic bool fields coerce int 1 to True.""" + model = RunAgentInput(username_agent_slug="user/agent", dry_run=1) # type: ignore[arg-type] + assert model.dry_run is True + + def test_dry_run_coerces_falsy_int(self): + """Pydantic bool fields coerce int 0 to False.""" + model = RunAgentInput(username_agent_slug="user/agent", dry_run=0) # type: ignore[arg-type] + assert model.dry_run is False + + def test_dry_run_with_wait_for_result(self): + """The guide instructs passing both dry_run=True and wait_for_result=120. + The model must accept this combination.""" + model = RunAgentInput( + username_agent_slug="user/agent", + dry_run=True, + wait_for_result=120, + ) + assert model.dry_run is True + assert model.wait_for_result == 120 + + def test_wait_for_result_upper_bound(self): + """wait_for_result is bounded at 300 seconds (ge=0, le=300).""" + with pytest.raises(ValidationError): + RunAgentInput( + username_agent_slug="user/agent", + dry_run=True, + wait_for_result=301, + ) + + def test_string_fields_are_stripped(self): + """The strip_strings validator should strip whitespace from string fields.""" + model = RunAgentInput(username_agent_slug=" user/agent ", dry_run=True) + assert model.username_agent_slug == "user/agent" + + +# --------------------------------------------------------------------------- +# Functional tests: guide documents the correct workflow ordering +# --------------------------------------------------------------------------- + + +class TestGuideWorkflowOrdering: + """Verify the guide documents workflow steps in the correct order. + + The LLM must see: create/edit -> dry-run -> inspect -> fix -> repeat. + If these steps are reordered, the copilot would follow the wrong sequence. + These tests verify *ordering*, not just presence. + """ + + @pytest.fixture + def guide_content(self) -> str: + guide_path = ( + Path(__file__).resolve().parent.parent.parent + / "backend" + / "copilot" + / "sdk" + / "agent_generation_guide.md" + ) + return guide_path.read_text(encoding="utf-8") + + def test_create_before_dry_run_in_workflow(self, guide_content: str): + """Step 7 (Save/create_agent) must appear before step 8 (Dry-run).""" + create_pos = guide_content.index("create_agent") + dry_run_pos = guide_content.index("dry_run=True") + assert ( + create_pos < dry_run_pos + ), "create_agent must appear before dry_run=True in the workflow" + + def test_dry_run_before_inspect_in_verification_section(self, guide_content: str): + """In the verification loop section, Dry-run step must come before + Inspect & fix step.""" + section_start = guide_content.index("REQUIRED: Dry-Run Verification Loop") + section = guide_content[section_start:] + dry_run_pos = section.index("**Dry-run**") + inspect_pos = section.index("**Inspect") + assert ( + dry_run_pos < inspect_pos + ), "Dry-run step must come before Inspect & fix in the verification loop" + + def test_fix_before_repeat_in_verification_section(self, guide_content: str): + """The Fix step must come before the Repeat step.""" + section_start = guide_content.index("REQUIRED: Dry-Run Verification Loop") + section = guide_content[section_start:] + fix_pos = section.index("**Fix**") + repeat_pos = section.index("**Repeat**") + assert fix_pos < repeat_pos + + def test_good_output_before_bad_output(self, guide_content: str): + """Good output examples should be listed before bad output examples, + so the LLM sees the success pattern first.""" + good_pos = guide_content.index("**Good output**") + bad_pos = guide_content.index("**Bad output**") + assert good_pos < bad_pos + + def test_numbered_steps_in_verification_section(self, guide_content: str): + """The step-by-step workflow should have numbered steps 1-5.""" + section_start = guide_content.index("Step-by-step workflow") + section = guide_content[section_start:] + # The section should contain numbered items 1 through 5 + for step_num in range(1, 6): + assert ( + f"{step_num}. " in section + ), f"Missing numbered step {step_num} in verification workflow" + + def test_workflow_steps_are_in_numbered_order(self, guide_content: str): + """The main workflow steps (1-9) must appear in ascending order.""" + # Extract the numbered workflow items from the top-level workflow section + workflow_start = guide_content.index("### Workflow for Creating/Editing Agents") + # End at the next ### section + next_section = guide_content.index("### Agent JSON Structure") + workflow_section = guide_content[workflow_start:next_section] + step_positions = [] + for step_num in range(1, 10): + pattern = rf"^{step_num}\.\s" + match = re.search(pattern, workflow_section, re.MULTILINE) + if match: + step_positions.append((step_num, match.start())) + # Verify at least steps 1-9 are present and in order + assert ( + len(step_positions) >= 9 + ), f"Expected 9 workflow steps, found {len(step_positions)}" + for i in range(1, len(step_positions)): + prev_num, prev_pos = step_positions[i - 1] + curr_num, curr_pos = step_positions[i] + assert prev_pos < curr_pos, ( + f"Step {prev_num} (pos {prev_pos}) should appear before " + f"step {curr_num} (pos {curr_pos})" + ) diff --git a/autogpt_platform/docker-compose.yml b/autogpt_platform/docker-compose.yml index 625761c0b5..0a8b412d57 100644 --- a/autogpt_platform/docker-compose.yml +++ b/autogpt_platform/docker-compose.yml @@ -98,6 +98,7 @@ services: - CLAMD_CONF_MaxScanSize=100M - CLAMD_CONF_MaxThreads=12 - CLAMD_CONF_ReadTimeout=300 + - CLAMD_CONF_TCPAddr=0.0.0.0 healthcheck: test: ["CMD-SHELL", "clamdscan --version || exit 1"] interval: 30s diff --git a/autogpt_platform/frontend/AGENTS.md b/autogpt_platform/frontend/AGENTS.md index e0accaadc1..152d0f239d 100644 --- a/autogpt_platform/frontend/AGENTS.md +++ b/autogpt_platform/frontend/AGENTS.md @@ -40,6 +40,8 @@ After making **any** code changes in the frontend, you MUST run the following co Do NOT skip these steps. If any command reports errors, fix them and re-run until clean. Only then may you consider the task complete. If typing keeps failing, stop and ask the user. +4. `pnpm test:unit` — run integration tests; fix any failures + ### Code Style - Fully capitalize acronyms in symbols, e.g. `graphID`, `useBackendAPI` @@ -62,7 +64,7 @@ Do NOT skip these steps. If any command reports errors, fix them and re-run unti - **Icons**: Phosphor Icons only - **Feature Flags**: LaunchDarkly integration - **Error Handling**: ErrorCard for render errors, toast for mutations, Sentry for exceptions -- **Testing**: Playwright for E2E, Storybook for component development +- **Testing**: Vitest + React Testing Library + MSW for integration tests (primary), Playwright for E2E, Storybook for visual ## Environment Configuration @@ -84,7 +86,12 @@ See @CONTRIBUTING.md for complete patterns. Quick reference: - Regenerate with `pnpm generate:api` - Pattern: `use{Method}{Version}{OperationName}` 4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only -5. **Testing**: Add Storybook stories for new components, Playwright for E2E. When fixing a bug, write a failing Playwright test first (use `.fixme` annotation), implement the fix, then remove the annotation. +5. **Testing**: Integration tests are the default (~90%). See `TESTING.md` for full details. + - **New pages/features**: Write integration tests in `__tests__/` next to `page.tsx` using Vitest + RTL + MSW + - **API mocking**: Use Orval-generated MSW handlers from `@/app/api/__generated__/endpoints/{tag}/{tag}.msw.ts` + - **Run**: `pnpm test:unit` (integration/unit), `pnpm test` (Playwright E2E) + - **Storybook**: For design system components in `src/components/` + - **TDD**: Write a failing test first, implement, then verify 6. **Code conventions**: - Use function declarations (not arrow functions) for components/handlers - Do not use `useCallback` or `useMemo` unless asked to optimise a given function diff --git a/autogpt_platform/frontend/CONTRIBUTING.md b/autogpt_platform/frontend/CONTRIBUTING.md index 649bb1ca92..bcb40f4430 100644 --- a/autogpt_platform/frontend/CONTRIBUTING.md +++ b/autogpt_platform/frontend/CONTRIBUTING.md @@ -747,9 +747,65 @@ export function CreateButton() { --- -## 🧪 Testing & Storybook +## 🧪 Testing -- See `TESTING.md` for Playwright setup, E2E data seeding, and Storybook usage. +See `TESTING.md` for full details. Key principles: + +### Integration tests are the default (~90% of tests) + +We test at the **page level**: render the page with React Testing Library, mock API requests with MSW (auto-generated by Orval), and assert with testing-library queries. + +```bash +pnpm test:unit # run integration/unit tests +pnpm test:unit:watch # watch mode +``` + +### Test file location + +Tests live in `__tests__/` next to the page or component: + +``` +app/(platform)/library/ + __tests__/ + main.test.tsx # main page rendering & interactions + search.test.tsx # search-specific behavior + components/ + page.tsx + useLibraryPage.ts +``` + +### Writing a test + +1. Render the page using `render()` from `@/tests/integrations/test-utils` +2. Mock API responses using Orval-generated MSW handlers from `@/app/api/__generated__/endpoints/{tag}/{tag}.msw.ts` +3. Assert with `screen.findByText`, `screen.getByRole`, etc. + +```tsx +import { render, screen } from "@/tests/integrations/test-utils"; +import { server } from "@/mocks/mock-server"; +import { getGetV2ListLibraryAgentsMockHandler200 } from "@/app/api/__generated__/endpoints/library/library.msw"; +import LibraryPage from "../page"; + +test("renders agent list", async () => { + server.use(getGetV2ListLibraryAgentsMockHandler200()); + render(); + expect(await screen.findByText("My Agents")).toBeDefined(); +}); +``` + +### When to use each test type + +| Type | When | +| ------------------------------------ | --------------------------------------------- | +| **Integration (Vitest + RTL + MSW)** | Default for all new pages and features | +| **E2E (Playwright)** | Auth flows, payments, cross-page navigation | +| **Storybook** | Design system components in `src/components/` | + +### TDD workflow + +1. Write a failing test (integration test or Playwright with `.fixme`) +2. Implement the fix/feature +3. Remove annotations and run the full suite --- @@ -763,8 +819,10 @@ Common scripts (see `package.json` for full list): - `pnpm lint` — ESLint + Prettier check - `pnpm format` — Format code - `pnpm types` — Type-check +- `pnpm test:unit` — Run integration/unit tests (Vitest + RTL + MSW) +- `pnpm test:unit:watch` — Watch mode for integration tests +- `pnpm test` — Run Playwright E2E tests - `pnpm storybook` — Run Storybook -- `pnpm test` — Run Playwright tests Generated API client: @@ -780,6 +838,7 @@ Generated API client: - Logic is separated into `use*.ts` and `helpers.ts` when non-trivial - Reusable logic extracted to `src/services/` or `src/lib/utils.ts` when appropriate - Navigation uses the Next.js router +- Integration tests added/updated for new pages and features (`pnpm test:unit`) - Lint, format, type-check, and tests pass locally - Stories updated/added if UI changed; verified in Storybook diff --git a/autogpt_platform/frontend/Dockerfile b/autogpt_platform/frontend/Dockerfile index 476a9a8ed3..58ce906cd4 100644 --- a/autogpt_platform/frontend/Dockerfile +++ b/autogpt_platform/frontend/Dockerfile @@ -12,6 +12,10 @@ COPY autogpt_platform/frontend/ . # Allow CI to opt-in to Playwright test build-time flags ARG NEXT_PUBLIC_PW_TEST="false" ENV NEXT_PUBLIC_PW_TEST=$NEXT_PUBLIC_PW_TEST +# Allow CI to opt-in to browser sourcemaps for coverage path resolution. +# Keep Docker builds defaulting to false to avoid the memory hit. +ARG NEXT_PUBLIC_SOURCEMAPS="false" +ENV NEXT_PUBLIC_SOURCEMAPS=$NEXT_PUBLIC_SOURCEMAPS ENV NODE_ENV="production" # Merge env files appropriately based on environment RUN if [ -f .env.production ]; then \ @@ -25,10 +29,6 @@ RUN if [ -f .env.production ]; then \ cp .env.default .env; \ fi RUN pnpm run generate:api -# Disable source-map generation in Docker builds to halve webpack memory usage. -# Source maps are only useful when SENTRY_AUTH_TOKEN is set (Vercel deploys); -# the Docker image never uploads them, so generating them just wastes RAM. -ENV NEXT_PUBLIC_SOURCEMAPS="false" # In CI, we want NEXT_PUBLIC_PW_TEST=true during build so Next.js inlines it RUN if [ "$NEXT_PUBLIC_PW_TEST" = "true" ]; then NEXT_PUBLIC_PW_TEST=true NODE_OPTIONS="--max-old-space-size=8192" pnpm build; else NODE_OPTIONS="--max-old-space-size=8192" pnpm build; fi diff --git a/autogpt_platform/frontend/TESTING.md b/autogpt_platform/frontend/TESTING.md index 2995295c96..0b95f8eaab 100644 --- a/autogpt_platform/frontend/TESTING.md +++ b/autogpt_platform/frontend/TESTING.md @@ -1,57 +1,168 @@ -# Frontend Testing 🧪 +# Frontend Testing -## Quick Start (local) 🚀 +## Testing Strategy + +| Type | Tool | Speed | When to use | +| ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- | +| **Integration (primary)** | Vitest + React Testing Library + MSW | Fast (~100ms) | ~90% of tests — page-level rendering with mocked API | +| **E2E** | Playwright | Slow (~5s) | Critical flows: auth, payments, cross-page navigation | +| **Visual** | Storybook + Chromatic | N/A | Design system components | + +**Integration tests are the default.** Since most of our code is client-only, we test at the page level: render the page with React Testing Library, mock API requests with MSW (handlers auto-generated by Orval), and assert with testing-library queries. + +## Integration Tests (Vitest + RTL + MSW) + +### Running + +```bash +pnpm test:unit # run all integration/unit tests with coverage +pnpm test:unit:watch # watch mode for development +``` + +### File location + +Tests live in a `__tests__/` folder next to the page or component they test: + +``` +app/(platform)/library/ + __tests__/ + main.test.tsx # tests the main page rendering & interactions + search.test.tsx # tests search-specific behavior + components/ + AgentCard/ + AgentCard.tsx + __tests__/ + AgentCard.test.tsx # only when testing the component in isolation + page.tsx + useLibraryPage.ts +``` + +**Naming**: use descriptive names like `main.test.tsx`, `search.test.tsx`, `filters.test.tsx` — not `page.test.tsx` or `index.test.tsx`. + +### Writing an integration test + +1. **Render the page** using the custom `render()` from `@/tests/integrations/test-utils` (wraps providers) +2. **Mock API responses** using Orval-generated MSW handlers from `@/app/api/__generated__/endpoints/{tag}/{tag}.msw.ts` +3. **Assert** with React Testing Library queries (`screen.findByText`, `screen.getByRole`, etc.) + +```tsx +import { render, screen } from "@/tests/integrations/test-utils"; +import { server } from "@/mocks/mock-server"; +import { + getGetV2ListLibraryAgentsMockHandler200, + getGetV2ListLibraryAgentsMockHandler422, +} from "@/app/api/__generated__/endpoints/library/library.msw"; +import LibraryPage from "../page"; + +describe("LibraryPage", () => { + test("renders agent list from API", async () => { + server.use(getGetV2ListLibraryAgentsMockHandler200()); + + render(); + + expect(await screen.findByText("My Agents")).toBeDefined(); + }); + + test("shows error state on API failure", async () => { + server.use(getGetV2ListLibraryAgentsMockHandler422()); + + render(); + + expect(await screen.findByText(/error/i)).toBeDefined(); + }); +}); +``` + +### MSW handlers + +Orval generates typed MSW handlers for every endpoint and HTTP status code: + +- `getGetV2ListLibraryAgentsMockHandler200()` — success response with faker data +- `getGetV2ListLibraryAgentsMockHandler422()` — validation error response +- `getGetV2ListLibraryAgentsMockHandler401()` — unauthorized response + +To override with custom data, pass a resolver: + +```tsx +import { http, HttpResponse } from "msw"; + +server.use( + http.get("http://localhost:3000/api/proxy/api/library/agents", () => { + return HttpResponse.json({ + agents: [{ id: "1", name: "My Agent" }], + pagination: { total: 1 }, + }); + }), +); +``` + +All handlers are aggregated in `src/mocks/mock-handlers.ts` and the MSW server is set up in `src/mocks/mock-server.ts`. + +### Test utilities + +- **`@/tests/integrations/test-utils`** — custom `render()` that wraps components with `QueryClientProvider`, `BackendAPIProvider`, `OnboardingProvider`, `NuqsTestingAdapter`, and `TooltipProvider`, so query-state hooks and tooltips work out of the box in page-level tests +- **`@/tests/integrations/setup-nextjs-mocks`** — mocks for `next/navigation`, `next/image`, `next/headers`, `next/link` +- **`@/tests/integrations/mock-supabase-request`** — mocks Supabase auth (returns null user by default) + +### What to test at page level + +- Page renders with API data (happy path) +- Loading and error states +- User interactions that trigger mutations (clicks, form submissions) +- Conditional rendering based on API responses +- Search, filtering, pagination behavior + +### When to test a component in isolation + +Only when the component has complex internal logic that is hard to exercise through the page test. Prefer page-level tests as the default. + +## E2E Tests (Playwright) + +### Running + +```bash +pnpm test # build + run all Playwright tests +pnpm test-ui # run with Playwright UI +pnpm test:no-build # run against a running dev server +``` + +### Setup 1. Start the backend + Supabase stack: - From `autogpt_platform`: `docker compose --profile local up deps_backend -d` - - Or run the full stack: `docker compose up -d` 2. Seed rich E2E data (creates `test123@gmail.com` with library agents): - From `autogpt_platform/backend`: `poetry run python test/e2e_test_data.py` -3. Run Playwright: - - From `autogpt_platform/frontend`: `pnpm test` or `pnpm test-ui` -## How Playwright setup works 🎭 +### How Playwright setup works -- Playwright runs from `frontend/playwright.config.ts` with a global setup step. -- The global setup creates a user pool via the real signup UI and stores it in `frontend/.auth/user-pool.json`. -- Most tests call `getTestUser()` (from `src/tests/utils/auth.ts`) which pulls a random user from that pool. - - these users do not contain library agents, it's user that just "signed up" on the platform, hence some tests to make use of users created via script (see below) with more data +- Playwright runs from `frontend/playwright.config.ts` with a global setup step +- Global setup creates a user pool via the real signup UI, stored in `frontend/.auth/user-pool.json` +- `getTestUser()` (from `src/tests/utils/auth.ts`) pulls a random user from the pool +- `getTestUserWithLibraryAgents()` uses the rich user created by the data script -## Test users 👤 +### Test users -- **User pool (basic users)** - Created automatically by the Playwright global setup through `/signup`. - Used by `getTestUser()` in `src/tests/utils/auth.ts`. +- **User pool (basic users)** — created automatically by Playwright global setup. Used by `getTestUser()` +- **Rich user with library agents** — created by `backend/test/e2e_test_data.py`. Used by `getTestUserWithLibraryAgents()` -- **Rich user with library agents** - Created by `backend/test/e2e_test_data.py`. - Accessed via `getTestUserWithLibraryAgents()` in `src/tests/credentials/index.ts`. - -Use the rich user when a test needs existing library agents (e.g. `library.spec.ts`). - -## Resetting or wiping the DB 🔁 +### Resetting the DB If you reset the Docker DB and logins start failing: -1. Delete `frontend/.auth/user-pool.json` so the pool is regenerated. -2. Re-run the E2E data script to recreate the rich user + library agents: - - `poetry run python test/e2e_test_data.py` +1. Delete `frontend/.auth/user-pool.json` +2. Re-run `poetry run python test/e2e_test_data.py` -## Storybook 📚 +## Storybook -## Flow diagram 🗺️ +- `pnpm storybook` — run locally +- `pnpm build-storybook` — build static +- `pnpm test-storybook` — CI runner +- When changing components in `src/components`, update or add stories and verify in Storybook/Chromatic -```mermaid -flowchart TD - A[Start Docker stack] --> B[Run e2e_test_data.py] - B --> C[Run Playwright tests] - C --> D[Global setup creates user pool] - D --> E{Test needs rich data?} - E -->|No| F[getTestUser from user pool] - E -->|Yes| G[getTestUserWithLibraryAgents] -``` +## TDD Workflow -- `pnpm storybook` – Run Storybook locally -- `pnpm build-storybook` – Build a static Storybook -- CI runner: `pnpm test-storybook` -- When changing components in `src/components`, update or add stories and verify in Storybook/Chromatic. +When fixing a bug or adding a feature: + +1. **Write a failing test first** — for integration tests, write the test and confirm it fails. For Playwright, use `.fixme` annotation +2. **Implement the fix/feature** — write the minimal code to make the test pass +3. **Remove annotations** — once passing, remove `.fixme` and run the full suite diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index bc172c1669..90c2645272 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -161,6 +161,7 @@ "eslint-plugin-storybook": "9.1.5", "happy-dom": "20.3.4", "import-in-the-middle": "2.0.2", + "monocart-reporter": "2.10.0", "msw": "2.11.6", "msw-storybook-addon": "2.0.6", "orval": "7.13.0", diff --git a/autogpt_platform/frontend/playwright.config.ts b/autogpt_platform/frontend/playwright.config.ts index 7604e8e88a..bf3c19845f 100644 --- a/autogpt_platform/frontend/playwright.config.ts +++ b/autogpt_platform/frontend/playwright.config.ts @@ -5,10 +5,57 @@ import { defineConfig, devices } from "@playwright/test"; * https://github.com/motdotla/dotenv */ import dotenv from "dotenv"; +import fs from "fs"; import path from "path"; dotenv.config({ path: path.resolve(__dirname, ".env") }); dotenv.config({ path: path.resolve(__dirname, "../backend/.env") }); +const frontendRoot = __dirname.replaceAll("\\", "/"); + +// Directory where CI copies .next/static from the Docker container +const staticCoverageDir = path.resolve(__dirname, ".next-static-coverage"); + +function normalizeCoverageSourcePath(filePath: string) { + const normalizedFilePath = filePath.replaceAll("\\", "/"); + const withoutWebpackPrefix = normalizedFilePath.replace( + /^webpack:\/\/_N_E\//, + "", + ); + + if (withoutWebpackPrefix.startsWith("./")) { + return withoutWebpackPrefix.slice(2); + } + + if (withoutWebpackPrefix.startsWith(frontendRoot)) { + return path.posix.relative(frontendRoot, withoutWebpackPrefix); + } + + return withoutWebpackPrefix; +} + +// Resolve source maps from the copied .next/static directory. +// Cache parsed results to avoid repeated disk reads during report generation. +const sourceMapCache = new Map(); + +function resolveSourceMap(sourcePath: string) { + // sourcePath is the sourceMappingURL, e.g.: + // "http://localhost:3000/_next/static/chunks/abc123.js.map" + const match = sourcePath.match(/_next\/static\/(.+)$/); + if (!match) return undefined; + + const mapFile = path.join(staticCoverageDir, match[1]); + if (sourceMapCache.has(mapFile)) return sourceMapCache.get(mapFile); + + try { + const result = JSON.parse(fs.readFileSync(mapFile, "utf8")) as object; + sourceMapCache.set(mapFile, result); + return result; + } catch { + sourceMapCache.set(mapFile, undefined); + return undefined; + } +} + export default defineConfig({ testDir: "./src/tests", /* Global setup file that runs before all tests */ @@ -22,7 +69,30 @@ export default defineConfig({ /* use more workers on CI. */ workers: process.env.CI ? 4 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [["list"], ["html", { open: "never" }]], + reporter: [ + ["list"], + ["html", { open: "never" }], + [ + "monocart-reporter", + { + name: "E2E Coverage Report", + outputFile: "./coverage/e2e/report.html", + coverage: { + reports: ["cobertura"], + outputDir: "./coverage/e2e", + entryFilter: (entry: { url: string }) => + entry.url.includes("/_next/static/") && + !entry.url.includes("node_modules"), + sourceFilter: (sourcePath: string) => + sourcePath.includes("src/") && !sourcePath.includes("node_modules"), + sourcePath: (filePath: string) => + normalizeCoverageSourcePath(filePath), + sourceMapResolver: (sourcePath: string) => + resolveSourceMap(sourcePath), + }, + }, + ], + ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ diff --git a/autogpt_platform/frontend/pnpm-lock.yaml b/autogpt_platform/frontend/pnpm-lock.yaml index 5baa9a50f6..95b49e3a22 100644 --- a/autogpt_platform/frontend/pnpm-lock.yaml +++ b/autogpt_platform/frontend/pnpm-lock.yaml @@ -400,6 +400,9 @@ importers: import-in-the-middle: specifier: 2.0.2 version: 2.0.2 + monocart-reporter: + specifier: 2.10.0 + version: 2.10.0 msw: specifier: 2.11.6 version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3) @@ -4064,6 +4067,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -4080,6 +4087,14 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} + engines: {node: '>=0.4.0'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -4610,9 +4625,20 @@ packages: console-browserify@1.2.0: resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==} + console-grid@2.2.3: + resolution: {integrity: sha512-+mecFacaFxGl+1G31IsCx41taUXuW2FxX+4xIE0TIPhgML+Jb9JFcBWGhhWerd1/vhScubdmHqTwOhB0KCUUAg==} + constants-browserify@1.0.0: resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -4623,6 +4649,10 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + core-js-compat@3.47.0: resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} @@ -4931,6 +4961,9 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -4957,6 +4990,17 @@ packages: delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dependency-graph@0.11.0: resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} engines: {node: '>= 0.6.0'} @@ -4968,6 +5012,10 @@ packages: des.js@1.1.0: resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -5049,6 +5097,12 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + eight-colors@1.3.2: + resolution: {integrity: sha512-qo7BAEbNnadiWn3EgZFD8tk2DWpifEHJE7CVyp09I0FiUJZ6z0YSyCGFmmtopVMi32iaL4hEK6m+/pPkx1iMFA==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -5081,6 +5135,10 @@ packages: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + endent@2.1.0: resolution: {integrity: sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==} @@ -5180,6 +5238,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -5493,6 +5554,10 @@ packages: react-dom: optional: true + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -5773,6 +5838,18 @@ packages: htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -6193,12 +6270,26 @@ packages: resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} hasBin: true + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa-static-resolver@1.0.6: + resolution: {integrity: sha512-ZX5RshSzH8nFn05/vUNQzqw32nEigsPa67AVUr6ZuQxuGdnCcTLcdgr4C81+YbJjpgqKHfacMBd7NmJIbj7fXw==} + + koa@3.2.0: + resolution: {integrity: sha512-TrM4/tnNY7uJ1aW55sIIa+dqBvc4V14WRIAlGcWat9wV5pRS9Wr5Zk2ZTjQP1jtfIHDoHiSbPuV08P0fUZo2pg==} + engines: {node: '>= 18'} + langium@3.3.1: resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} engines: {node: '>=16.0.0'} @@ -6351,6 +6442,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + lz-utils@2.1.0: + resolution: {integrity: sha512-CMkfimAypidTtWjNDxY8a1bc1mJdyEh04V2FfEQ5Zh8Nx4v7k850EYa+dOWGn9hKG5xOyHP5MkuduAZCTHRvJw==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -6456,6 +6550,10 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -6598,10 +6696,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -6640,6 +6746,17 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + monocart-coverage-reports@2.12.9: + resolution: {integrity: sha512-vtFqbC3Egl4nVa1FSIrQvMPO6HZtb9lo+3IW7/crdvrLNW2IH8lUsxaK0TsKNmMO2mhFWwqQywLV2CZelqPgwA==} + hasBin: true + + monocart-locator@1.0.2: + resolution: {integrity: sha512-v8W5hJLcWMIxLCcSi/MHh+VeefI+ycFmGz23Froer9QzWjrbg4J3gFJBuI/T1VLNoYxF47bVPPxq8ZlNX4gVCw==} + + monocart-reporter@2.10.0: + resolution: {integrity: sha512-Q421HL8hCr024HMjQcQylEpOLy69FE6Zli2s/A0zptfFEPW/kaz6B1Ll3CYs8L1j67+egt1HeNC1LTHUsp6W+A==} + hasBin: true + motion-dom@12.24.8: resolution: {integrity: sha512-wX64WITk6gKOhaTqhsFqmIkayLAAx45SVFiMnJIxIrH5uqyrwrxjrfo8WX9Kh8CaUAixjeMn82iH0W0QT9wD5w==} @@ -6688,6 +6805,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -6757,6 +6878,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nodemailer@7.0.13: + resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -6851,6 +6976,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -6953,6 +7082,10 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} @@ -7751,6 +7884,9 @@ packages: setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sha.js@2.4.12: resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} engines: {node: '>= 0.10'} @@ -7872,6 +8008,10 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -8157,6 +8297,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@6.0.0: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} @@ -8228,6 +8372,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tty-browserify@0.0.1: resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==} @@ -8257,6 +8405,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -8457,6 +8609,10 @@ packages: resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} engines: {node: '>= 0.10'} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -12911,6 +13067,11 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -12923,6 +13084,14 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-loose@8.5.2: + dependencies: + acorn: 8.15.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.15.0 + acorn@8.15.0: {} adjust-sourcemap-loader@4.0.0: @@ -13472,14 +13641,25 @@ snapshots: console-browserify@1.2.0: {} + console-grid@2.2.3: {} + constants-browserify@1.0.0: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} cookie@1.0.2: {} + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + core-js-compat@3.47.0: dependencies: browserslist: 4.28.1 @@ -13843,6 +14023,8 @@ snapshots: deep-eql@5.0.2: {} + deep-equal@1.0.1: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -13867,6 +14049,12 @@ snapshots: dependencies: robust-predicates: 3.0.2 + delegates@1.0.0: {} + + depd@1.1.2: {} + + depd@2.0.0: {} + dependency-graph@0.11.0: {} dequal@2.0.3: {} @@ -13876,6 +14064,8 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 + destroy@1.2.0: {} + detect-libc@2.1.2: optional: true @@ -13958,6 +14148,10 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + + eight-colors@1.3.2: {} + electron-to-chromium@1.5.267: {} elliptic@6.6.1: @@ -13990,6 +14184,8 @@ snapshots: emojis-list@3.0.0: {} + encodeurl@2.0.0: {} + endent@2.1.0: dependencies: dedent: 0.7.0 @@ -14209,6 +14405,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -14606,6 +14804,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + fresh@0.5.2: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -14994,6 +15194,27 @@ snapshots: domutils: 2.8.0 entities: 2.2.0 + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -15409,12 +15630,41 @@ snapshots: dependencies: commander: 8.3.0 + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 khroma@2.1.0: {} + koa-compose@4.1.0: {} + + koa-static-resolver@1.0.6: {} + + koa@3.2.0: + dependencies: + accepts: 1.3.8 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookies: 0.9.1 + delegates: 1.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 2.0.1 + koa-compose: 4.1.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + langium@3.3.1: dependencies: chevrotain: 11.0.3 @@ -15552,6 +15802,8 @@ snapshots: lz-string@1.5.0: {} + lz-utils@2.1.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -15771,6 +16023,8 @@ snapshots: mdurl@2.0.0: {} + media-typer@1.1.0: {} + memfs@3.5.3: dependencies: fs-monkey: 1.1.0 @@ -16047,10 +16301,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-fn@2.1.0: {} min-indent@1.0.1: {} @@ -16084,6 +16344,34 @@ snapshots: module-details-from-path@1.0.4: {} + monocart-coverage-reports@2.12.9: + dependencies: + acorn: 8.15.0 + acorn-loose: 8.5.2 + acorn-walk: 8.3.5 + commander: 14.0.2 + console-grid: 2.2.3 + eight-colors: 1.3.2 + foreground-child: 3.3.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + lz-utils: 2.1.0 + monocart-locator: 1.0.2 + + monocart-locator@1.0.2: {} + + monocart-reporter@2.10.0: + dependencies: + console-grid: 2.2.3 + eight-colors: 1.3.2 + koa: 3.2.0 + koa-static-resolver: 1.0.6 + lz-utils: 2.1.0 + monocart-coverage-reports: 2.12.9 + monocart-locator: 1.0.2 + nodemailer: 7.0.13 + motion-dom@12.24.8: dependencies: motion-utils: 12.23.28 @@ -16138,6 +16426,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + neo-async@2.6.2: {} next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -16237,6 +16527,8 @@ snapshots: node-releases@2.0.27: {} + nodemailer@7.0.13: {} + normalize-path@3.0.0: {} npm-run-path@4.0.1: @@ -16338,6 +16630,10 @@ snapshots: obug@2.1.1: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -16495,6 +16791,8 @@ snapshots: entities: 6.0.1 optional: true + parseurl@1.3.3: {} + pascal-case@3.1.2: dependencies: no-case: 3.0.4 @@ -17365,6 +17663,8 @@ snapshots: setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} + sha.js@2.4.12: dependencies: inherits: 2.0.4 @@ -17526,6 +17826,8 @@ snapshots: dependencies: type-fest: 0.7.1 + statuses@1.5.0: {} + statuses@2.0.2: {} std-env@3.10.0: {} @@ -17873,6 +18175,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tough-cookie@6.0.0: dependencies: tldts: 7.0.19 @@ -17930,6 +18234,8 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + tty-browserify@0.0.1: {} twemoji-parser@14.0.0: {} @@ -17953,6 +18259,12 @@ snapshots: type-fest@4.41.0: {} + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -18182,6 +18494,8 @@ snapshots: validator@13.15.26: {} + vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/store.test.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/store.test.ts new file mode 100644 index 0000000000..f28d1fc2cb --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/store.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useOnboardingWizardStore } from "../store"; + +beforeEach(() => { + useOnboardingWizardStore.getState().reset(); +}); + +describe("useOnboardingWizardStore", () => { + describe("initial state", () => { + it("starts at step 1 with empty fields", () => { + const state = useOnboardingWizardStore.getState(); + expect(state.currentStep).toBe(1); + expect(state.name).toBe(""); + expect(state.role).toBe(""); + expect(state.otherRole).toBe(""); + expect(state.painPoints).toEqual([]); + expect(state.otherPainPoint).toBe(""); + }); + }); + + describe("setName", () => { + it("updates the name", () => { + useOnboardingWizardStore.getState().setName("Alice"); + expect(useOnboardingWizardStore.getState().name).toBe("Alice"); + }); + }); + + describe("setRole", () => { + it("updates the role", () => { + useOnboardingWizardStore.getState().setRole("Engineer"); + expect(useOnboardingWizardStore.getState().role).toBe("Engineer"); + }); + }); + + describe("setOtherRole", () => { + it("updates the other role text", () => { + useOnboardingWizardStore.getState().setOtherRole("Designer"); + expect(useOnboardingWizardStore.getState().otherRole).toBe("Designer"); + }); + }); + + describe("togglePainPoint", () => { + it("adds a pain point", () => { + useOnboardingWizardStore.getState().togglePainPoint("slow builds"); + expect(useOnboardingWizardStore.getState().painPoints).toEqual([ + "slow builds", + ]); + }); + + it("removes a pain point when toggled again", () => { + useOnboardingWizardStore.getState().togglePainPoint("slow builds"); + useOnboardingWizardStore.getState().togglePainPoint("slow builds"); + expect(useOnboardingWizardStore.getState().painPoints).toEqual([]); + }); + + it("handles multiple pain points", () => { + useOnboardingWizardStore.getState().togglePainPoint("slow builds"); + useOnboardingWizardStore.getState().togglePainPoint("no tests"); + expect(useOnboardingWizardStore.getState().painPoints).toEqual([ + "slow builds", + "no tests", + ]); + + useOnboardingWizardStore.getState().togglePainPoint("slow builds"); + expect(useOnboardingWizardStore.getState().painPoints).toEqual([ + "no tests", + ]); + }); + + it("ignores new selections when at the max limit", () => { + useOnboardingWizardStore.getState().togglePainPoint("a"); + useOnboardingWizardStore.getState().togglePainPoint("b"); + useOnboardingWizardStore.getState().togglePainPoint("c"); + useOnboardingWizardStore.getState().togglePainPoint("d"); + expect(useOnboardingWizardStore.getState().painPoints).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("still allows deselecting when at the max limit", () => { + useOnboardingWizardStore.getState().togglePainPoint("a"); + useOnboardingWizardStore.getState().togglePainPoint("b"); + useOnboardingWizardStore.getState().togglePainPoint("c"); + useOnboardingWizardStore.getState().togglePainPoint("b"); + expect(useOnboardingWizardStore.getState().painPoints).toEqual([ + "a", + "c", + ]); + }); + }); + + describe("setOtherPainPoint", () => { + it("updates the other pain point text", () => { + useOnboardingWizardStore.getState().setOtherPainPoint("flaky CI"); + expect(useOnboardingWizardStore.getState().otherPainPoint).toBe( + "flaky CI", + ); + }); + }); + + describe("nextStep", () => { + it("increments the step", () => { + useOnboardingWizardStore.getState().nextStep(); + expect(useOnboardingWizardStore.getState().currentStep).toBe(2); + }); + + it("clamps at step 4", () => { + useOnboardingWizardStore.getState().goToStep(4); + useOnboardingWizardStore.getState().nextStep(); + expect(useOnboardingWizardStore.getState().currentStep).toBe(4); + }); + }); + + describe("prevStep", () => { + it("decrements the step", () => { + useOnboardingWizardStore.getState().goToStep(3); + useOnboardingWizardStore.getState().prevStep(); + expect(useOnboardingWizardStore.getState().currentStep).toBe(2); + }); + + it("clamps at step 1", () => { + useOnboardingWizardStore.getState().prevStep(); + expect(useOnboardingWizardStore.getState().currentStep).toBe(1); + }); + }); + + describe("goToStep", () => { + it("jumps to an arbitrary step", () => { + useOnboardingWizardStore.getState().goToStep(3); + expect(useOnboardingWizardStore.getState().currentStep).toBe(3); + }); + }); + + describe("reset", () => { + it("resets all fields to defaults", () => { + useOnboardingWizardStore.getState().setName("Alice"); + useOnboardingWizardStore.getState().setRole("Engineer"); + useOnboardingWizardStore.getState().setOtherRole("Other"); + useOnboardingWizardStore.getState().togglePainPoint("slow builds"); + useOnboardingWizardStore.getState().setOtherPainPoint("flaky CI"); + useOnboardingWizardStore.getState().goToStep(3); + + useOnboardingWizardStore.getState().reset(); + + const state = useOnboardingWizardStore.getState(); + expect(state.currentStep).toBe(1); + expect(state.name).toBe(""); + expect(state.role).toBe(""); + expect(state.otherRole).toBe(""); + expect(state.painPoints).toEqual([]); + expect(state.otherPainPoint).toBe(""); + }); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/ProgressBar.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/ProgressBar.tsx index aee653d93f..71819d7d4c 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/ProgressBar.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/ProgressBar.tsx @@ -7,9 +7,9 @@ export function ProgressBar({ currentStep, totalSteps }: Props) { const percent = (currentStep / totalSteps) * 100; return ( -
+
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/SelectableCard.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/SelectableCard.tsx index 7559ff3e21..574f02fd7b 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/SelectableCard.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/SelectableCard.tsx @@ -2,6 +2,7 @@ import { Text } from "@/components/atoms/Text/Text"; import { cn } from "@/lib/utils"; +import { Check } from "@phosphor-icons/react"; interface Props { icon: React.ReactNode; @@ -24,13 +25,18 @@ export function SelectableCard({ onClick={onClick} aria-pressed={selected} className={cn( - "flex h-[9rem] w-[10.375rem] shrink-0 flex-col items-center justify-center gap-3 rounded-xl border-2 bg-white px-6 py-5 transition-all hover:shadow-sm md:shrink lg:gap-2 lg:px-10 lg:py-8", + "relative flex h-[9rem] w-[10.375rem] shrink-0 flex-col items-center justify-center gap-3 rounded-xl border-2 bg-white px-6 py-5 transition-all hover:shadow-sm md:shrink lg:gap-2 lg:px-10 lg:py-8", className, selected ? "border-purple-500 bg-purple-50 shadow-sm" : "border-transparent", )} > + {selected && ( + + + + )} - Pick the tasks you'd love to hand off to Autopilot + Pick the tasks you'd love to hand off to AutoPilot
@@ -107,11 +110,22 @@ export function PainPointsStep() { /> ))}
- {!hasSomethingElse ? ( - - Pick as many as you want — you can always change later - - ) : null} + + {shaking + ? "You've picked 3 — tap one to swap it out" + : atLimit && canContinue + ? "3 selected — you're all set!" + : atLimit && hasSomethingElse + ? "Tell us what else takes up your time" + : "Pick up to 3 to start — AutoPilot can help with anything else later"} + {hasSomethingElse && ( @@ -133,7 +147,7 @@ export function PainPointsStep() { disabled={!canContinue} className="w-full max-w-xs" > - Launch Autopilot + Launch AutoPilot diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/RoleStep.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/RoleStep.tsx index 79704e3e31..9bb6af42cd 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/RoleStep.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/RoleStep.tsx @@ -8,6 +8,7 @@ import { FadeIn } from "@/components/atoms/FadeIn/FadeIn"; import { SelectableCard } from "../components/SelectableCard"; import { useOnboardingWizardStore } from "../store"; import { Emoji } from "@/components/atoms/Emoji/Emoji"; +import { useEffect, useRef } from "react"; const IMG_SIZE = 42; @@ -57,12 +58,26 @@ export function RoleStep() { const setRole = useOnboardingWizardStore((s) => s.setRole); const setOtherRole = useOnboardingWizardStore((s) => s.setOtherRole); const nextStep = useOnboardingWizardStore((s) => s.nextStep); + const autoAdvanceTimer = useRef | null>(null); const isOther = role === "Other"; - const canContinue = role && (!isOther || otherRole.trim()); - function handleContinue() { - if (canContinue) { + useEffect(() => { + return () => { + if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current); + }; + }, []); + + function handleRoleSelect(id: string) { + if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current); + setRole(id); + if (id !== "Other") { + autoAdvanceTimer.current = setTimeout(nextStep, 350); + } + } + + function handleOtherContinue() { + if (otherRole.trim()) { nextStep(); } } @@ -78,7 +93,7 @@ export function RoleStep() { What best describes you, {name}? - Autopilot will tailor automations to your world + So AutoPilot knows how to help you best @@ -89,33 +104,35 @@ export function RoleStep() { icon={r.icon} label={r.label} selected={role === r.id} - onClick={() => setRole(r.id)} + onClick={() => handleRoleSelect(r.id)} className="p-8" /> ))} {isOther && ( -
- setOtherRole(e.target.value)} - autoFocus - /> -
- )} + <> +
+ setOtherRole(e.target.value)} + autoFocus + /> +
- + + + )} ); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/WelcomeStep.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/WelcomeStep.tsx index fa054161cc..06ce9b57b7 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/WelcomeStep.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/WelcomeStep.tsx @@ -4,13 +4,6 @@ import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo"; import { Button } from "@/components/atoms/Button/Button"; import { Input } from "@/components/atoms/Input/Input"; import { Text } from "@/components/atoms/Text/Text"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/atoms/Tooltip/BaseTooltip"; -import { Question } from "@phosphor-icons/react"; import { FadeIn } from "@/components/atoms/FadeIn/FadeIn"; import { useOnboardingWizardStore } from "../store"; @@ -40,36 +33,16 @@ export function WelcomeStep() { Welcome to AutoGPT Let's personalize your experience so{" "} - - Autopilot - - - - - - - - Autopilot is AutoGPT's AI assistant that watches your - connected apps, spots repetitive tasks you do every day - and runs them for you automatically. - - - - + + AutoPilot {" "} - can start saving you time right away + can start saving you time setName(e.target.value)} diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/PainPointsStep.test.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/PainPointsStep.test.tsx new file mode 100644 index 0000000000..f6843f7998 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/PainPointsStep.test.tsx @@ -0,0 +1,154 @@ +import { + render, + screen, + fireEvent, + cleanup, +} from "@/tests/integrations/test-utils"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useOnboardingWizardStore } from "../../store"; +import { PainPointsStep } from "../PainPointsStep"; + +vi.mock("@/components/atoms/Emoji/Emoji", () => ({ + Emoji: ({ text }: { text: string }) => {text}, +})); + +vi.mock("@/components/atoms/FadeIn/FadeIn", () => ({ + FadeIn: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +function getCard(name: RegExp) { + return screen.getByRole("button", { name }); +} + +function clickCard(name: RegExp) { + fireEvent.click(getCard(name)); +} + +function getLaunchButton() { + return screen.getByRole("button", { name: /launch autopilot/i }); +} + +afterEach(cleanup); + +beforeEach(() => { + useOnboardingWizardStore.getState().reset(); + useOnboardingWizardStore.getState().setName("Alice"); + useOnboardingWizardStore.getState().setRole("Founder/CEO"); + useOnboardingWizardStore.getState().goToStep(3); +}); + +describe("PainPointsStep", () => { + test("renders all pain point cards", () => { + render(); + + expect(getCard(/finding leads/i)).toBeDefined(); + expect(getCard(/email & outreach/i)).toBeDefined(); + expect(getCard(/reports & data/i)).toBeDefined(); + expect(getCard(/customer support/i)).toBeDefined(); + expect(getCard(/social media/i)).toBeDefined(); + expect(getCard(/something else/i)).toBeDefined(); + }); + + test("shows default helper text", () => { + render(); + + expect( + screen.getAllByText(/pick up to 3 to start/i).length, + ).toBeGreaterThan(0); + }); + + test("selecting a card marks it as pressed", () => { + render(); + + clickCard(/finding leads/i); + + expect(getCard(/finding leads/i).getAttribute("aria-pressed")).toBe("true"); + }); + + test("launch button is disabled when nothing is selected", () => { + render(); + + expect(getLaunchButton().hasAttribute("disabled")).toBe(true); + }); + + test("launch button is enabled after selecting a pain point", () => { + render(); + + clickCard(/finding leads/i); + + expect(getLaunchButton().hasAttribute("disabled")).toBe(false); + }); + + test("shows success text when 3 items are selected", () => { + render(); + + clickCard(/finding leads/i); + clickCard(/email & outreach/i); + clickCard(/reports & data/i); + + expect(screen.getAllByText(/3 selected/i).length).toBeGreaterThan(0); + }); + + test("does not select a 4th item when at the limit", () => { + render(); + + clickCard(/finding leads/i); + clickCard(/email & outreach/i); + clickCard(/reports & data/i); + clickCard(/customer support/i); + + expect(getCard(/customer support/i).getAttribute("aria-pressed")).toBe( + "false", + ); + }); + + test("can deselect when at the limit and select a different one", () => { + render(); + + clickCard(/finding leads/i); + clickCard(/email & outreach/i); + clickCard(/reports & data/i); + + clickCard(/finding leads/i); + expect(getCard(/finding leads/i).getAttribute("aria-pressed")).toBe( + "false", + ); + + clickCard(/customer support/i); + expect(getCard(/customer support/i).getAttribute("aria-pressed")).toBe( + "true", + ); + }); + + test("shows input when 'Something else' is selected", () => { + render(); + + clickCard(/something else/i); + + expect( + screen.getByPlaceholderText(/what else takes up your time/i), + ).toBeDefined(); + }); + + test("launch button is disabled when 'Something else' selected but input empty", () => { + render(); + + clickCard(/something else/i); + + expect(getLaunchButton().hasAttribute("disabled")).toBe(true); + }); + + test("launch button is enabled when 'Something else' selected and input filled", () => { + render(); + + clickCard(/something else/i); + fireEvent.change( + screen.getByPlaceholderText(/what else takes up your time/i), + { target: { value: "Manual invoicing" } }, + ); + + expect(getLaunchButton().hasAttribute("disabled")).toBe(false); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/RoleStep.test.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/RoleStep.test.tsx new file mode 100644 index 0000000000..0cafccab98 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/RoleStep.test.tsx @@ -0,0 +1,123 @@ +import { + render, + screen, + fireEvent, + cleanup, +} from "@/tests/integrations/test-utils"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useOnboardingWizardStore } from "../../store"; +import { RoleStep } from "../RoleStep"; + +vi.mock("@/components/atoms/Emoji/Emoji", () => ({ + Emoji: ({ text }: { text: string }) => {text}, +})); + +vi.mock("@/components/atoms/FadeIn/FadeIn", () => ({ + FadeIn: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +beforeEach(() => { + vi.useFakeTimers(); + useOnboardingWizardStore.getState().reset(); + useOnboardingWizardStore.getState().setName("Alice"); + useOnboardingWizardStore.getState().goToStep(2); +}); + +describe("RoleStep", () => { + test("renders all role cards", () => { + render(); + + expect(screen.getByText("Founder / CEO")).toBeDefined(); + expect(screen.getByText("Operations")).toBeDefined(); + expect(screen.getByText("Sales / BD")).toBeDefined(); + expect(screen.getByText("Marketing")).toBeDefined(); + expect(screen.getByText("Product / PM")).toBeDefined(); + expect(screen.getByText("Engineering")).toBeDefined(); + expect(screen.getByText("HR / People")).toBeDefined(); + expect(screen.getByText("Other")).toBeDefined(); + }); + + test("displays the user name in the heading", () => { + render(); + + expect( + screen.getAllByText(/what best describes you, alice/i).length, + ).toBeGreaterThan(0); + }); + + test("selecting a non-Other role auto-advances after delay", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /engineering/i })); + + expect(useOnboardingWizardStore.getState().role).toBe("Engineering"); + expect(useOnboardingWizardStore.getState().currentStep).toBe(2); + + vi.advanceTimersByTime(350); + + expect(useOnboardingWizardStore.getState().currentStep).toBe(3); + }); + + test("selecting 'Other' does not auto-advance", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + + vi.advanceTimersByTime(500); + + expect(useOnboardingWizardStore.getState().currentStep).toBe(2); + }); + + test("selecting 'Other' shows text input and Continue button", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + + expect(screen.getByPlaceholderText(/describe your role/i)).toBeDefined(); + expect(screen.getByRole("button", { name: /continue/i })).toBeDefined(); + }); + + test("Continue button is disabled when Other input is empty", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + + const continueBtn = screen.getByRole("button", { name: /continue/i }); + expect(continueBtn.hasAttribute("disabled")).toBe(true); + }); + + test("Continue button advances when Other role text is filled", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + fireEvent.change(screen.getByPlaceholderText(/describe your role/i), { + target: { value: "Designer" }, + }); + + const continueBtn = screen.getByRole("button", { name: /continue/i }); + expect(continueBtn.hasAttribute("disabled")).toBe(false); + + fireEvent.click(continueBtn); + expect(useOnboardingWizardStore.getState().currentStep).toBe(3); + }); + + test("switching from Other to a regular role cancels Other and auto-advances", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + expect(screen.getByPlaceholderText(/describe your role/i)).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: /marketing/i })); + + expect(useOnboardingWizardStore.getState().role).toBe("Marketing"); + vi.advanceTimersByTime(350); + expect(useOnboardingWizardStore.getState().currentStep).toBe(3); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/usePainPointsStep.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/usePainPointsStep.ts index bf8f5e59cc..384a43e80c 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/usePainPointsStep.ts +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/usePainPointsStep.ts @@ -1,4 +1,5 @@ -import { useOnboardingWizardStore } from "../store"; +import { useEffect, useRef, useState } from "react"; +import { MAX_PAIN_POINT_SELECTIONS, useOnboardingWizardStore } from "../store"; const ROLE_TOP_PICKS: Record = { "Founder/CEO": [ @@ -23,18 +24,38 @@ export function usePainPointsStep() { const role = useOnboardingWizardStore((s) => s.role); const painPoints = useOnboardingWizardStore((s) => s.painPoints); const otherPainPoint = useOnboardingWizardStore((s) => s.otherPainPoint); - const togglePainPoint = useOnboardingWizardStore((s) => s.togglePainPoint); + const storeToggle = useOnboardingWizardStore((s) => s.togglePainPoint); const setOtherPainPoint = useOnboardingWizardStore( (s) => s.setOtherPainPoint, ); const nextStep = useOnboardingWizardStore((s) => s.nextStep); + const [shaking, setShaking] = useState(false); + const shakeTimer = useRef | null>(null); + + useEffect(() => { + return () => { + if (shakeTimer.current) clearTimeout(shakeTimer.current); + }; + }, []); const topIDs = getTopPickIDs(role); const hasSomethingElse = painPoints.includes("Something else"); + const atLimit = painPoints.length >= MAX_PAIN_POINT_SELECTIONS; const canContinue = painPoints.length > 0 && (!hasSomethingElse || Boolean(otherPainPoint.trim())); + function togglePainPoint(id: string) { + const alreadySelected = painPoints.includes(id); + if (!alreadySelected && atLimit) { + if (shakeTimer.current) clearTimeout(shakeTimer.current); + setShaking(true); + shakeTimer.current = setTimeout(() => setShaking(false), 600); + return; + } + storeToggle(id); + } + function handleLaunch() { if (canContinue) { nextStep(); @@ -48,6 +69,8 @@ export function usePainPointsStep() { togglePainPoint, setOtherPainPoint, hasSomethingElse, + atLimit, + shaking, canContinue, handleLaunch, }; diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/store.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/store.ts index edc5ffa020..fe5e52b8c1 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/store.ts +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/store.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; +export const MAX_PAIN_POINT_SELECTIONS = 3; export type Step = 1 | 2 | 3 | 4; interface OnboardingWizardState { @@ -40,6 +41,8 @@ export const useOnboardingWizardStore = create( togglePainPoint(painPoint) { set((state) => { const exists = state.painPoints.includes(painPoint); + if (!exists && state.painPoints.length >= MAX_PAIN_POINT_SELECTIONS) + return state; return { painPoints: exists ? state.painPoints.filter((p) => p !== painPoint) diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx index d7b2e11819..b25dba32a4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx @@ -40,14 +40,14 @@ export const ContentRenderer: React.FC<{ !shortContent ) { return ( -
+
{renderer?.render(value, metadata)}
); } return ( -
+
); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx index 90084bc535..46fbe1ed6e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx @@ -8,6 +8,7 @@ import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag"; import { SidebarProvider } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; import { UploadSimple } from "@phosphor-icons/react"; +import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; import { ChatContainer } from "./components/ChatContainer/ChatContainer"; import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar"; @@ -20,6 +21,14 @@ import { RateLimitResetDialog } from "./components/RateLimitResetDialog/RateLimi import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader"; import { useCopilotPage } from "./useCopilotPage"; +const ArtifactPanel = dynamic( + () => + import("./components/ArtifactPanel/ArtifactPanel").then( + (m) => m.ArtifactPanel, + ), + { ssr: false }, +); + export function CopilotPage() { const [isDragging, setIsDragging] = useState(false); const [droppedFiles, setDroppedFiles] = useState([]); @@ -80,6 +89,10 @@ export function CopilotPage() { isUploadingFiles, isUserLoading, isLoggedIn, + // Pagination + hasMoreMessages, + isLoadingMore, + loadMore, // Mobile drawer isMobile, isDrawerOpen, @@ -116,6 +129,7 @@ export function CopilotPage() { const resetCost = usage?.reset_cost; const isBillingEnabled = useGetFlag(Flag.ENABLE_PLATFORM_PAYMENT); + const isArtifactsEnabled = useGetFlag(Flag.ARTIFACTS); const { credits, fetchCredits } = useCredits({ fetchInitialCredits: true }); const hasInsufficientCredits = credits !== null && resetCost != null && credits < resetCost; @@ -150,48 +164,55 @@ export function CopilotPage() { className="h-[calc(100vh-72px)] min-h-0" > {!isMobile && } -
- {isMobile && } - - {/* Drop overlay */} +
- - - Drop files here - -
-
- + {isMobile && } + + {/* Drop overlay */} +
+ + + Drop files here + +
+
+ +
+ {!isMobile && isArtifactsEnabled && }
+ {isMobile && isArtifactsEnabled && } {isMobile && ( ({ + captureException: vi.fn(), +})); + +vi.mock("@/services/environment", () => ({ + environment: { + isServerSide: vi.fn(() => false), + }, +})); + +describe("useCopilotUIStore", () => { + beforeEach(() => { + window.localStorage.clear(); + useCopilotUIStore.setState({ + initialPrompt: null, + sessionToDelete: null, + isDrawerOpen: false, + completedSessionIDs: new Set(), + isNotificationsEnabled: false, + isSoundEnabled: true, + showNotificationDialog: false, + copilotMode: "extended_thinking", + }); + }); + + describe("initialPrompt", () => { + it("starts as null", () => { + expect(useCopilotUIStore.getState().initialPrompt).toBeNull(); + }); + + it("sets and clears prompt", () => { + useCopilotUIStore.getState().setInitialPrompt("Hello"); + expect(useCopilotUIStore.getState().initialPrompt).toBe("Hello"); + + useCopilotUIStore.getState().setInitialPrompt(null); + expect(useCopilotUIStore.getState().initialPrompt).toBeNull(); + }); + }); + + describe("sessionToDelete", () => { + it("starts as null", () => { + expect(useCopilotUIStore.getState().sessionToDelete).toBeNull(); + }); + + it("sets and clears a delete target", () => { + useCopilotUIStore + .getState() + .setSessionToDelete({ id: "abc", title: "Test" }); + expect(useCopilotUIStore.getState().sessionToDelete).toEqual({ + id: "abc", + title: "Test", + }); + + useCopilotUIStore.getState().setSessionToDelete(null); + expect(useCopilotUIStore.getState().sessionToDelete).toBeNull(); + }); + }); + + describe("drawer", () => { + it("starts closed", () => { + expect(useCopilotUIStore.getState().isDrawerOpen).toBe(false); + }); + + it("opens and closes", () => { + useCopilotUIStore.getState().setDrawerOpen(true); + expect(useCopilotUIStore.getState().isDrawerOpen).toBe(true); + + useCopilotUIStore.getState().setDrawerOpen(false); + expect(useCopilotUIStore.getState().isDrawerOpen).toBe(false); + }); + }); + + describe("completedSessionIDs", () => { + it("starts empty", () => { + expect(useCopilotUIStore.getState().completedSessionIDs.size).toBe(0); + }); + + it("adds a completed session", () => { + useCopilotUIStore.getState().addCompletedSession("s1"); + expect(useCopilotUIStore.getState().completedSessionIDs.has("s1")).toBe( + true, + ); + }); + + it("persists added sessions to localStorage", () => { + useCopilotUIStore.getState().addCompletedSession("s1"); + useCopilotUIStore.getState().addCompletedSession("s2"); + const raw = window.localStorage.getItem("copilot-completed-sessions"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!) as string[]; + expect(parsed).toContain("s1"); + expect(parsed).toContain("s2"); + }); + + it("clears a single completed session", () => { + useCopilotUIStore.getState().addCompletedSession("s1"); + useCopilotUIStore.getState().addCompletedSession("s2"); + useCopilotUIStore.getState().clearCompletedSession("s1"); + expect(useCopilotUIStore.getState().completedSessionIDs.has("s1")).toBe( + false, + ); + expect(useCopilotUIStore.getState().completedSessionIDs.has("s2")).toBe( + true, + ); + }); + + it("updates localStorage when a session is cleared", () => { + useCopilotUIStore.getState().addCompletedSession("s1"); + useCopilotUIStore.getState().addCompletedSession("s2"); + useCopilotUIStore.getState().clearCompletedSession("s1"); + const raw = window.localStorage.getItem("copilot-completed-sessions"); + const parsed = JSON.parse(raw!) as string[]; + expect(parsed).not.toContain("s1"); + expect(parsed).toContain("s2"); + }); + + it("clears all completed sessions", () => { + useCopilotUIStore.getState().addCompletedSession("s1"); + useCopilotUIStore.getState().addCompletedSession("s2"); + useCopilotUIStore.getState().clearAllCompletedSessions(); + expect(useCopilotUIStore.getState().completedSessionIDs.size).toBe(0); + }); + + it("removes localStorage key when all sessions are cleared", () => { + useCopilotUIStore.getState().addCompletedSession("s1"); + useCopilotUIStore.getState().clearAllCompletedSessions(); + expect( + window.localStorage.getItem("copilot-completed-sessions"), + ).toBeNull(); + }); + }); + + describe("sound toggle", () => { + it("starts enabled", () => { + expect(useCopilotUIStore.getState().isSoundEnabled).toBe(true); + }); + + it("toggles sound off and on", () => { + useCopilotUIStore.getState().toggleSound(); + expect(useCopilotUIStore.getState().isSoundEnabled).toBe(false); + + useCopilotUIStore.getState().toggleSound(); + expect(useCopilotUIStore.getState().isSoundEnabled).toBe(true); + }); + + it("persists to localStorage", () => { + useCopilotUIStore.getState().toggleSound(); + expect(window.localStorage.getItem("copilot-sound-enabled")).toBe( + "false", + ); + }); + }); + + describe("copilotMode", () => { + it("defaults to extended_thinking", () => { + expect(useCopilotUIStore.getState().copilotMode).toBe( + "extended_thinking", + ); + }); + + it("sets mode to fast", () => { + useCopilotUIStore.getState().setCopilotMode("fast"); + expect(useCopilotUIStore.getState().copilotMode).toBe("fast"); + expect(window.localStorage.getItem("copilot-mode")).toBe("fast"); + }); + + it("sets mode back to extended_thinking", () => { + useCopilotUIStore.getState().setCopilotMode("fast"); + useCopilotUIStore.getState().setCopilotMode("extended_thinking"); + expect(useCopilotUIStore.getState().copilotMode).toBe( + "extended_thinking", + ); + }); + }); + + describe("clearCopilotLocalData", () => { + it("resets state and clears localStorage keys", () => { + useCopilotUIStore.getState().setCopilotMode("fast"); + useCopilotUIStore.getState().setNotificationsEnabled(true); + useCopilotUIStore.getState().toggleSound(); + useCopilotUIStore.getState().addCompletedSession("s1"); + + useCopilotUIStore.getState().clearCopilotLocalData(); + + const state = useCopilotUIStore.getState(); + expect(state.copilotMode).toBe("extended_thinking"); + expect(state.isNotificationsEnabled).toBe(false); + expect(state.isSoundEnabled).toBe(true); + expect(state.completedSessionIDs.size).toBe(0); + expect(window.localStorage.getItem("copilot-mode")).toBeNull(); + expect( + window.localStorage.getItem("copilot-notifications-enabled"), + ).toBeNull(); + expect(window.localStorage.getItem("copilot-sound-enabled")).toBeNull(); + expect( + window.localStorage.getItem("copilot-completed-sessions"), + ).toBeNull(); + }); + }); + + describe("notifications", () => { + it("sets notification preference", () => { + useCopilotUIStore.getState().setNotificationsEnabled(true); + expect(useCopilotUIStore.getState().isNotificationsEnabled).toBe(true); + expect(window.localStorage.getItem("copilot-notifications-enabled")).toBe( + "true", + ); + }); + + it("shows and hides notification dialog", () => { + useCopilotUIStore.getState().setShowNotificationDialog(true); + expect(useCopilotUIStore.getState().showNotificationDialog).toBe(true); + + useCopilotUIStore.getState().setShowNotificationDialog(false); + expect(useCopilotUIStore.getState().showNotificationDialog).toBe(false); + }); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactCard/ArtifactCard.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactCard/ArtifactCard.tsx new file mode 100644 index 0000000000..554d760215 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactCard/ArtifactCard.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { toast } from "@/components/molecules/Toast/use-toast"; +import { cn } from "@/lib/utils"; +import { CaretRight, DownloadSimple } from "@phosphor-icons/react"; +import type { ArtifactRef } from "../../store"; +import { useCopilotUIStore } from "../../store"; +import { downloadArtifact } from "../ArtifactPanel/downloadArtifact"; +import { classifyArtifact } from "../ArtifactPanel/helpers"; + +interface Props { + artifact: ArtifactRef; +} + +function formatSize(bytes?: number): string { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function ArtifactCard({ artifact }: Props) { + const activeID = useCopilotUIStore((s) => s.artifactPanel.activeArtifact?.id); + const isOpen = useCopilotUIStore((s) => s.artifactPanel.isOpen); + const openArtifact = useCopilotUIStore((s) => s.openArtifact); + + const isActive = isOpen && activeID === artifact.id; + const classification = classifyArtifact( + artifact.mimeType, + artifact.title, + artifact.sizeBytes, + ); + const Icon = classification.icon; + + function handleDownloadOnly() { + downloadArtifact(artifact).catch(() => { + toast({ + title: "Download failed", + description: "Couldn't fetch the file.", + variant: "destructive", + }); + }); + } + + if (!classification.openable) { + return ( + + ); + } + + return ( + + ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.tsx new file mode 100644 index 0000000000..78e79e50e8 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArtifactContent } from "./components/ArtifactContent"; +import { ArtifactDragHandle } from "./components/ArtifactDragHandle"; +import { ArtifactMinimizedStrip } from "./components/ArtifactMinimizedStrip"; +import { ArtifactPanelHeader } from "./components/ArtifactPanelHeader"; +import { useArtifactPanel } from "./useArtifactPanel"; + +interface Props { + mobile?: boolean; +} + +export function ArtifactPanel({ mobile }: Props) { + const { + isOpen, + isMinimized, + isMaximized, + activeArtifact, + history, + effectiveWidth, + isSourceView, + classification, + setIsSourceView, + closeArtifactPanel, + minimizeArtifactPanel, + maximizeArtifactPanel, + restoreArtifactPanel, + setArtifactPanelWidth, + goBackArtifact, + canCopy, + handleCopy, + handleDownload, + } = useArtifactPanel(); + + if (!activeArtifact || !classification) return null; + + const headerProps = { + artifact: activeArtifact, + classification, + canGoBack: history.length > 0, + isMaximized, + isSourceView, + hasSourceToggle: classification.hasSourceToggle, + mobile: !!mobile, + canCopy, + onBack: goBackArtifact, + onClose: closeArtifactPanel, + onMinimize: minimizeArtifactPanel, + onMaximize: maximizeArtifactPanel, + onRestore: restoreArtifactPanel, + onCopy: handleCopy, + onDownload: handleDownload, + onSourceToggle: setIsSourceView, + }; + + // Mobile: fullscreen Sheet overlay + if (mobile) { + return ( + !open && closeArtifactPanel()} + > + + + {activeArtifact.title} + + + + + + ); + } + + // Minimized strip + if (isOpen && isMinimized) { + return ( + + ); + } + + // Keep AnimatePresence mounted across the open→closed transition so the + // exit animation on the motion.div has a chance to run. + return ( + + {isOpen && ( + + + + + + )} + + ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx new file mode 100644 index 0000000000..6e057293b5 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { globalRegistry } from "@/components/contextual/OutputRenderers"; +import { codeRenderer } from "@/components/contextual/OutputRenderers/renderers/CodeRenderer"; +import { Suspense } from "react"; +import type { ArtifactRef } from "../../../store"; +import type { ArtifactClassification } from "../helpers"; +import { ArtifactReactPreview } from "./ArtifactReactPreview"; +import { ArtifactSkeleton } from "./ArtifactSkeleton"; +import { + TAILWIND_CDN_URL, + wrapWithHeadInjection, +} from "@/lib/iframe-sandbox-csp"; +import { useArtifactContent } from "./useArtifactContent"; + +interface Props { + artifact: ArtifactRef; + isSourceView: boolean; + classification: ArtifactClassification; +} + +function ArtifactContentLoader({ + artifact, + isSourceView, + classification, +}: Props) { + const { content, pdfUrl, isLoading, error, scrollRef, retry } = + useArtifactContent(artifact, classification); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+

Failed to load content

+

{error}

+ +
+ ); + } + + return ( +
+ +
+ ); +} + +function ArtifactRenderer({ + artifact, + content, + pdfUrl, + isSourceView, + classification, +}: { + artifact: ArtifactRef; + content: string | null; + pdfUrl: string | null; + isSourceView: boolean; + classification: ArtifactClassification; +}) { + // Image: render directly from URL (no content fetch) + if (classification.type === "image") { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {artifact.title} +
+ ); + } + + if (classification.type === "pdf" && pdfUrl) { + // No sandbox — Chrome/Edge block PDF rendering in sandboxed iframes + // (Chromium bug #413851). The blob URL has a null origin so it can't + // access the parent page regardless. + return ( +