feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls * feat(webui): add project workspaces and access controls * refactor(tools): centralize workspace access resolution * refactor(webui): remove unused workspace host state * fix(webui): hide estimated file edit label * fix(webui): clarify file edit deletion feedback * fix(webui): label deleted file activity * fix(webui): flatten file edit activity rows * fix(core): remove path-only patch deletion * fix(core): keep apply patch non-destructive * refactor(webui): trim workspace host plumbing * fix(tools): register exec with tools config
This commit is contained in:
@@ -12,6 +12,7 @@ import nanobot.providers.base as provider_base
|
||||
from nanobot.providers.openai_codex_provider import (
|
||||
OpenAICodexProvider,
|
||||
_codex_error_response,
|
||||
_build_reasoning_options,
|
||||
_CodexHTTPError,
|
||||
_friendly_error,
|
||||
_request_codex,
|
||||
@@ -128,11 +129,12 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
|
||||
body,
|
||||
verify,
|
||||
on_content_delta=None,
|
||||
on_thinking_delta=None,
|
||||
on_tool_call_delta=None,
|
||||
):
|
||||
_ = on_tool_call_delta
|
||||
_ = on_thinking_delta, on_tool_call_delta
|
||||
bodies.append(body)
|
||||
return "ok", [], "stop"
|
||||
return "ok", [], "stop", None
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||
|
||||
@@ -257,7 +259,7 @@ async def test_codex_retry_uses_structured_timeout_metadata(monkeypatch) -> None
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise httpx.ReadTimeout("")
|
||||
return "ok", [], "stop"
|
||||
return "ok", [], "stop", None
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
@@ -397,3 +399,56 @@ def test_codex_429_classification_uses_raw_error_semantics(
|
||||
error_type, error_code = provider_base.LLMProvider._extract_error_type_code(raw)
|
||||
|
||||
assert _should_retry_status(429, error_type, error_code, raw) is expected_retry
|
||||
|
||||
|
||||
def test_codex_reasoning_options_request_summary_without_forcing_effort() -> None:
|
||||
assert _build_reasoning_options(None) == {"summary": "auto"}
|
||||
assert _build_reasoning_options("high") == {"summary": "auto", "effort": "high"}
|
||||
assert _build_reasoning_options("none") == {"effort": "none"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_provider.get_codex_token",
|
||||
lambda: SimpleNamespace(account_id="acct", access="token"),
|
||||
)
|
||||
|
||||
async def fake_request(
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
verify,
|
||||
on_content_delta=None,
|
||||
on_thinking_delta=None,
|
||||
on_tool_call_delta=None,
|
||||
):
|
||||
_ = url, headers, verify, on_tool_call_delta
|
||||
assert body["reasoning"] == {"summary": "auto", "effort": "medium"}
|
||||
if on_content_delta:
|
||||
await on_content_delta("answer")
|
||||
if on_thinking_delta:
|
||||
await on_thinking_delta("summary")
|
||||
return "answer", [], "stop", "summary"
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||
|
||||
provider = OpenAICodexProvider()
|
||||
content_deltas: list[str] = []
|
||||
thinking_deltas: list[str] = []
|
||||
|
||||
response = await provider.chat_stream(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
reasoning_effort="medium",
|
||||
on_content_delta=lambda delta: _append(content_deltas, delta),
|
||||
on_thinking_delta=lambda delta: _append(thinking_deltas, delta),
|
||||
)
|
||||
|
||||
assert content_deltas == ["answer"]
|
||||
assert thinking_deltas == ["summary"]
|
||||
assert response.content == "answer"
|
||||
assert response.reasoning_content == "summary"
|
||||
|
||||
|
||||
async def _append(target: list[str], value: str) -> None:
|
||||
target.append(value)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Tests for the shared openai_responses converters and parsers."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.openai_responses.converters import (
|
||||
convert_messages,
|
||||
convert_tools,
|
||||
@@ -13,6 +13,8 @@ from nanobot.providers.openai_responses.converters import (
|
||||
)
|
||||
from nanobot.providers.openai_responses.parsing import (
|
||||
consume_sdk_stream,
|
||||
consume_sse,
|
||||
consume_sse_with_reasoning,
|
||||
map_finish_reason,
|
||||
parse_response_output,
|
||||
)
|
||||
@@ -434,6 +436,166 @@ class TestParseResponseOutput:
|
||||
assert result.usage["total_tokens"] == 150
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# parsing - consume_sse
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class _SseResponse:
|
||||
def __init__(self, events: list[dict]):
|
||||
self._events = events
|
||||
|
||||
async def aiter_lines(self):
|
||||
for event in self._events:
|
||||
yield f"data: {json.dumps(event)}"
|
||||
yield ""
|
||||
|
||||
|
||||
class TestConsumeSse:
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_consume_sse_returns_three_tuple(self):
|
||||
response = _SseResponse([
|
||||
{"type": "response.output_text.delta", "delta": "hi"},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
|
||||
content, tool_calls, finish_reason = await consume_sse(response)
|
||||
|
||||
assert content == "hi"
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_delta_extracted(self):
|
||||
response = _SseResponse([
|
||||
{"type": "response.reasoning_summary_text.delta", "delta": "thinking "},
|
||||
{"type": "response.reasoning_summary_text.delta", "delta": "briefly"},
|
||||
{"type": "response.output_text.delta", "delta": "answer"},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
deltas: list[str] = []
|
||||
|
||||
async def on_reasoning(delta: str) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
content, tool_calls, finish_reason, reasoning = await consume_sse_with_reasoning(
|
||||
response,
|
||||
on_reasoning_delta=on_reasoning,
|
||||
)
|
||||
|
||||
assert content == "answer"
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
assert reasoning == "thinking briefly"
|
||||
assert deltas == ["thinking ", "briefly"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_from_completed_response(self):
|
||||
response = _SseResponse([
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{"type": "reasoning", "summary": [
|
||||
{"type": "summary_text", "text": "cached "},
|
||||
{"type": "summary_text", "text": "summary"},
|
||||
]},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
_, _, _, reasoning = await consume_sse_with_reasoning(response)
|
||||
|
||||
assert reasoning == "cached summary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_from_done_item(self):
|
||||
response = _SseResponse([
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "done summary"}],
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {"status": "completed", "output": []}},
|
||||
])
|
||||
deltas: list[str] = []
|
||||
|
||||
async def on_reasoning(delta: str) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
_, _, _, reasoning = await consume_sse_with_reasoning(
|
||||
response,
|
||||
on_reasoning_delta=on_reasoning,
|
||||
)
|
||||
|
||||
assert reasoning == "done summary"
|
||||
assert deltas == ["done summary"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_part_done_extracted(self):
|
||||
response = _SseResponse([
|
||||
{
|
||||
"type": "response.reasoning_summary_part.done",
|
||||
"part": {"type": "summary_text", "text": "part summary"},
|
||||
},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
|
||||
_, _, _, reasoning = await consume_sse_with_reasoning(response)
|
||||
|
||||
assert reasoning == "part summary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_done_arguments_callback(self):
|
||||
response = _SseResponse([
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"id": "fc1",
|
||||
"name": "write_file",
|
||||
"arguments": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.function_call_arguments.done",
|
||||
"call_id": "c1",
|
||||
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"id": "fc1",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
deltas: list[dict] = []
|
||||
|
||||
async def cb(delta: dict) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
await consume_sse_with_reasoning(response, on_tool_call_delta=cb)
|
||||
|
||||
assert deltas == [
|
||||
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# parsing - consume_sdk_stream
|
||||
# ======================================================================
|
||||
@@ -544,6 +706,46 @@ class TestConsumeSdkStream:
|
||||
"arguments_delta": '{"path":"a.txt","content":"',
|
||||
},
|
||||
{"call_id": "c1", "name": "write_file", "arguments_delta": "hello\\n"},
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_done_item_arguments_callback_without_delta(self):
|
||||
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
||||
item_added.name = "write_file"
|
||||
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
||||
item_done = MagicMock(
|
||||
type="function_call",
|
||||
call_id="c1",
|
||||
id="fc1",
|
||||
arguments='{"path":"late.txt","content":"done\\n"}',
|
||||
)
|
||||
item_done.name = "write_file"
|
||||
ev2 = MagicMock(type="response.output_item.done", item=item_done)
|
||||
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
||||
ev3 = MagicMock(type="response.completed", response=resp_obj)
|
||||
deltas: list[dict] = []
|
||||
|
||||
async def cb(delta: dict) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
async def stream():
|
||||
for e in [ev1, ev2, ev3]:
|
||||
yield e
|
||||
|
||||
await consume_sdk_stream(stream(), on_tool_call_delta=cb)
|
||||
|
||||
assert deltas == [
|
||||
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path":"late.txt","content":"done\\n"}',
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user