mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
### Why / What / How Users need a way to choose between fast, cheap responses (Sonnet) and deep reasoning (Opus) in the copilot. Previously only the SDK/Opus path existed, and the baseline path was a degraded fallback with no tool calling, no file attachments, no E2B sandbox, and no permission enforcement. This PR adds a copilot mode toggle and brings the baseline (fast) path to full feature parity with the SDK (extended thinking) path. ### Changes 🏗️ #### 1. Mode toggle (UI → full stack) - Add Fast / Thinking mode toggle to ChatInput footer (Phosphor `Brain`/`Zap` icons via lucide-react) - Thread `mode: "fast" | "extended_thinking" | null` from `StreamChatRequest` → RabbitMQ queue → executor → service selection - Fast → baseline service (Sonnet 4 via OpenRouter), Thinking → SDK service (Opus 4.6) - Toggle gated behind `CHAT_MODE_OPTION` feature flag with server-side enforcement - Mode persists in localStorage with SSR-safe init #### 2. Baseline service full tool parity - **Tool call persistence**: Store structured `ChatMessage` entries (assistant + tool results) instead of flat concatenated text — enables frontend to render tool call details and maintain context across turns - **E2B sandbox**: Wire up `get_or_create_sandbox()` so `bash_exec` routes to E2B (image download, Python/PIL compression, filesystem access) - **File attachments**: Accept `file_ids`, download workspace files, embed images as OpenAI vision blocks, save non-images to working dir - **Permissions**: Filter tool list via `CopilotPermissions` (whitelist/blacklist) - **URL context**: Pass `context` dict to user message for URL-shared content - **Execution context**: Pass `sandbox`, `sdk_cwd`, `permissions` to `set_execution_context()` - **Model**: Changed `fast_model` from `google/gemini-2.5-flash` to `anthropic/claude-sonnet-4` for reliable function calling - **Temp dir cleanup**: Lazy `mkdtemp` (only when files attached) + `shutil.rmtree` in finally #### 3. Transcript support for Fast mode - Baseline service now downloads / validates / loads / appends / uploads transcripts (parity with SDK) - Enables seamless mode switching mid-conversation via shared transcript - Upload shielded from cancellation, bounded at 5s timeout #### 4. Feature-flag infrastructure fixes - `FORCE_FLAG_*` env-var overrides on both backend and frontend for local dev / E2E - LaunchDarkly context parity (frontend mirrors backend user context) - `CHAT_MODE_OPTION` default flipped to `false` to match backend #### 5. Other hardening - Double-submit ref guard in `useChatInput` + reconnect dedup in `useCopilotStream` - `copilotModeRef` pattern to read latest mode without recreating transport - Shared `CopilotMode` type across frontend files - File name collision handling with numeric suffix - Path sanitization in file description hints (`os.path.basename`) ### Test plan - [x] 30 new unit tests: `_env_flag_override` (12), `envFlagOverride` (8), `_filter_tools_by_permissions` (4), `_prepare_baseline_attachments` (6) - [x] E2E tested on dev: fast mode creates E2B sandbox, calls 7-10 tools, generates and renders images - [x] Mode switching mid-session works (shared transcript + session messages) - [x] Server-side flag gate enforced (crafted `mode=fast` stripped when flag off) - [x] All 37 CI checks green - [x] Verified via agent-browser: workspace images render correctly in all message positions 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Zamil Majdy <majdy.zamil@gmail.com>
261 lines
8.4 KiB
Python
261 lines
8.4 KiB
Python
"""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
|