mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-26 18:35:38 +08:00
fix(box): validate and export explicitly selected files (#2641)
* fix(box): retain outbox files when export fails * test(box): retain ownership evidence in CI logs * test(box): exercise LocalAgent export repair through real runtime and Docker Co-authored-by: multica-agent <[email protected]> * fix(box): complete bounded outbox recovery contract * feat(box): export explicitly selected files Co-authored-by: multica-agent <[email protected]> * test(box): exercise explicit export on real Docker Co-authored-by: multica-agent <[email protected]> * test(box): verify explicit exports with live model and Docker Co-authored-by: multica-agent <[email protected]> * fix(box): align explicit export SDK and integration contracts * docs(box): describe selected-file export contract --------- Co-authored-by: HaiYangBG1 <[email protected]> Co-authored-by: Chin Rock <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
HaiYangBG1
Chin Rock
parent
bce94bd0d9
commit
947d8addea
@@ -21,6 +21,8 @@ def service():
|
||||
enabled=True,
|
||||
managed_admission_required=False,
|
||||
_ATTACHMENT_MAX_TOTAL_BYTES=1000000,
|
||||
clear_selected_files=AsyncMock(return_value=None),
|
||||
_ATTACHMENT_MAX_FILES=100,
|
||||
create_session=AsyncMock(side_effect=create),
|
||||
require_workspace_sandbox=AsyncMock(side_effect=lambda context: context),
|
||||
_action_context=lambda context: context,
|
||||
@@ -110,12 +112,12 @@ async def test_selective_import_is_idempotent_and_separates_duplicate_names():
|
||||
async def test_export_does_not_send_and_references_cannot_cross_runs_or_repeat():
|
||||
api = service()
|
||||
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
|
||||
api.box.collect_outbound_attachments = AsyncMock(
|
||||
api.box.collect_selected_files = AsyncMock(
|
||||
return_value=[
|
||||
{'name': 'answer.txt', 'type': 'File', 'base64': base64.b64encode(b'answer').decode()},
|
||||
]
|
||||
)
|
||||
result = await api.export_files(q)
|
||||
result = await api.export_files(q, ['/workspace/a'])
|
||||
file = result['items'][0]
|
||||
assert 'base64' not in file and file['size'] == 6
|
||||
other = SimpleNamespace(_box_binding=RunBoxBinding('other-run', 'box', {}, 'other-run'))
|
||||
@@ -172,11 +174,65 @@ def test_attachment_references_follow_metadata_not_platform_list_order():
|
||||
async def test_image_export_accepts_data_url_and_enforces_total_bytes():
|
||||
api = service()
|
||||
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
|
||||
api.box.collect_outbound_attachments = AsyncMock(
|
||||
api.box.collect_selected_files = AsyncMock(
|
||||
return_value=[{'name': 'a.png', 'type': 'Image', 'base64': 'data:image/png;base64,YQ=='}]
|
||||
)
|
||||
result = await api.export_files(q)
|
||||
result = await api.export_files(q, ['/workspace/a'])
|
||||
assert result['items'][0]['size'] == 1
|
||||
# Retrying unchanged content reuses the accepted ID; new content consumes budget.
|
||||
assert await api.export_files(q, ['/workspace/a']) == result
|
||||
api.box.collect_selected_files.return_value[0]['base64'] = 'data:image/png;base64,Yg=='
|
||||
api.box._ATTACHMENT_MAX_TOTAL_BYTES = 1
|
||||
with pytest.raises(BoxValidationError, match='byte limit'):
|
||||
await api.export_files(q)
|
||||
await api.export_files(q, ['/workspace/a'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_retry_preserves_ids_and_never_redelivers_consumed_files():
|
||||
api = service()
|
||||
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
|
||||
# A best-effort cleanup can leave the same files in the outbox.
|
||||
api.box.collect_selected_files = AsyncMock(
|
||||
return_value=[
|
||||
{'name': 'same.txt', '_outbox_path': 'a/same.txt', 'type': 'File', 'base64': 'YQ=='},
|
||||
{'name': 'same.txt', '_outbox_path': 'b/same.txt', 'type': 'File', 'base64': 'YQ=='},
|
||||
{'name': 'empty.txt', '_outbox_path': 'empty.txt', 'type': 'File', 'base64': ''},
|
||||
]
|
||||
)
|
||||
first = await api.export_files(q, ['/workspace/a'])
|
||||
assert len(first['items']) == 3
|
||||
assert all('_outbox_path' not in item for item in first['items'])
|
||||
assert await api.export_files(q, ['/workspace/a']) == first
|
||||
exported_message(q, [item['id'] for item in first['items']], consume=True)
|
||||
assert await api.export_files(q, ['/workspace/a']) == {'items': [], 'warning': None}
|
||||
assert len(q._box_binding.exported) == 3
|
||||
api.box.collect_selected_files.return_value[0]['base64'] = 'Yg=='
|
||||
changed = await api.export_files(q, ['/workspace/a'])
|
||||
assert len(changed['items']) == 1
|
||||
assert changed['items'][0]['id'] not in {item['id'] for item in first['items']}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_quota_failure_is_atomic_and_reports_remaining_budget():
|
||||
from langbot.pkg.box.export_errors import BoxExportError
|
||||
|
||||
api = service()
|
||||
api.box._ATTACHMENT_MAX_TOTAL_BYTES = 2
|
||||
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
|
||||
api.box.collect_selected_files = AsyncMock(
|
||||
return_value=[
|
||||
{'name': 'a.txt', 'type': 'File', 'base64': 'YQ=='},
|
||||
]
|
||||
)
|
||||
await api.export_files(q, ['/workspace/a'])
|
||||
api.box.collect_selected_files.return_value = [
|
||||
{'name': 'b.txt', 'type': 'File', 'base64': 'Yg=='},
|
||||
{'name': 'c.txt', 'type': 'File', 'base64': 'Yw=='},
|
||||
]
|
||||
with pytest.raises(BoxExportError) as caught:
|
||||
await api.export_files(q, ['/workspace/a'])
|
||||
assert caught.value.error.details['limit'] == 2
|
||||
assert caught.value.error.details['actual'] == 3
|
||||
assert caught.value.error.details['remaining'] == 1
|
||||
assert len(q._box_binding.exported) == len(q._box_binding.exported_fingerprints) == 1
|
||||
api.box.clear_selected_files.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user