375 lines
17 KiB
Python
375 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
|
|
|
|
PNG = b"\x89PNG\r\n\x1a\n" + b"test-image"
|
|
|
|
|
|
def create_plain_conversation(logged_in):
|
|
client, app, projects, _, headers = logged_in
|
|
folder = projects / "queue-project"
|
|
folder.mkdir()
|
|
project = client.post("/api/projects", headers=headers, json={"name": "Queue", "path": str(folder)}).json()
|
|
conversation = client.post(
|
|
f"/api/projects/{project['id']}/conversations", headers=headers, json={"title": "Photos"}
|
|
).json()
|
|
return client, app, project, conversation, headers
|
|
|
|
|
|
def test_image_upload_is_authenticated_validated_and_private(logged_in):
|
|
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
|
uploaded = client.post(
|
|
f"/api/conversations/{conversation['id']}/attachments",
|
|
headers=headers,
|
|
files={"image": ("screen.png", PNG, "image/png")},
|
|
)
|
|
assert uploaded.status_code == 201
|
|
attachment = uploaded.json()
|
|
assert attachment["content_type"] == "image/png"
|
|
assert attachment["url"].startswith("/api/attachments/")
|
|
fetched = client.get(attachment["url"])
|
|
assert fetched.status_code == 200
|
|
assert fetched.content == PNG
|
|
client.cookies.clear()
|
|
assert client.get(attachment["url"]).status_code == 401
|
|
|
|
|
|
def test_invalid_image_payload_is_rejected(logged_in):
|
|
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
|
response = client.post(
|
|
f"/api/conversations/{conversation['id']}/attachments",
|
|
headers=headers,
|
|
files={"image": ("fake.png", b"not-an-image", "image/png")},
|
|
)
|
|
assert response.status_code == 415
|
|
|
|
|
|
def test_conversation_can_display_an_image_from_its_worktree(logged_in):
|
|
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
|
workspace = app.state.settings.data_dir / "worktrees" / conversation["id"]
|
|
output = workspace / "output"
|
|
output.mkdir(parents=True)
|
|
(output / "result.webp").write_bytes(b"RIFFxxxxWEBPpreview")
|
|
(output / "notes.txt").write_text("not an image", encoding="utf-8")
|
|
app.state.db.execute(
|
|
"UPDATE conversations SET worktree_path=? WHERE id=?",
|
|
(str(workspace), conversation["id"]),
|
|
)
|
|
|
|
response = client.get(
|
|
f"/api/conversations/{conversation['id']}/files/image",
|
|
params={"path": "output/result.webp"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("image/webp")
|
|
assert response.headers["content-disposition"].startswith("inline;")
|
|
assert client.get(
|
|
f"/api/conversations/{conversation['id']}/files/image",
|
|
params={"path": "output/notes.txt"},
|
|
).status_code == 415
|
|
assert client.get(
|
|
f"/api/conversations/{conversation['id']}/files/image",
|
|
params={"path": "../secret.png"},
|
|
).status_code == 404
|
|
|
|
|
|
def test_conversation_can_download_an_artifact_from_its_worktree(logged_in):
|
|
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
|
workspace = app.state.settings.data_dir / "worktrees" / conversation["id"]
|
|
output = workspace / "output"
|
|
output.mkdir(parents=True)
|
|
(output / "boards.zip").write_bytes(b"PK\x03\x04archive")
|
|
app.state.db.execute(
|
|
"UPDATE conversations SET worktree_path=? WHERE id=?",
|
|
(str(workspace), conversation["id"]),
|
|
)
|
|
|
|
response = client.get(
|
|
f"/api/conversations/{conversation['id']}/files/download",
|
|
params={"path": "output/boards.zip"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.content == b"PK\x03\x04archive"
|
|
assert response.headers["content-disposition"].startswith("attachment;")
|
|
assert "no-store" in response.headers["cache-control"]
|
|
assert client.get(
|
|
f"/api/conversations/{conversation['id']}/files/download",
|
|
params={"path": "../secret.zip"},
|
|
).status_code == 404
|
|
client.cookies.clear()
|
|
assert client.get(
|
|
f"/api/conversations/{conversation['id']}/files/download",
|
|
params={"path": "output/boards.zip"},
|
|
).status_code == 401
|
|
|
|
|
|
def test_regular_file_can_be_uploaded_and_downloaded(logged_in):
|
|
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
|
payload = b"hello from an attached document\n"
|
|
uploaded = client.post(
|
|
f"/api/conversations/{conversation['id']}/attachments",
|
|
headers=headers,
|
|
files={"image": ("notes.txt", payload, "text/plain")},
|
|
)
|
|
assert uploaded.status_code == 201
|
|
attachment = uploaded.json()
|
|
assert attachment["original_name"] == "notes.txt"
|
|
assert attachment["content_type"] == "text/plain"
|
|
fetched = client.get(attachment["url"])
|
|
assert fetched.status_code == 200
|
|
assert fetched.content == payload
|
|
assert fetched.headers["content-disposition"].startswith("attachment;")
|
|
|
|
|
|
def test_file_larger_than_ten_megabytes_can_be_uploaded(logged_in):
|
|
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
|
payload = b"x" * (10 * 1024 * 1024 + 1)
|
|
uploaded = client.post(
|
|
f"/api/conversations/{conversation['id']}/attachments",
|
|
headers=headers,
|
|
files={"image": ("large.txt", payload, "text/plain")},
|
|
)
|
|
assert uploaded.status_code == 201
|
|
assert uploaded.json()["size"] == len(payload)
|
|
|
|
|
|
def test_file_over_configured_limit_is_rejected_and_removed(logged_in, monkeypatch):
|
|
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
|
monkeypatch.setattr("codexpi.main.MAX_ATTACHMENT_BYTES", 4)
|
|
response = client.post(
|
|
f"/api/conversations/{conversation['id']}/attachments",
|
|
headers=headers,
|
|
files={"image": ("too-large.txt", b"12345", "text/plain")},
|
|
)
|
|
assert response.status_code == 413
|
|
assert not app.state.db.one("SELECT id FROM attachments WHERE conversation_id=?", (conversation["id"],))
|
|
assert not any((app.state.settings.data_dir / "uploads" / conversation["id"]).iterdir())
|
|
|
|
|
|
def test_text_attachment_can_be_opened_and_edited_by_its_owner(logged_in):
|
|
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
|
uploaded = client.post(
|
|
f"/api/conversations/{conversation['id']}/attachments",
|
|
headers=headers,
|
|
files={"image": ("long-message.txt", b"original text", "text/plain")},
|
|
).json()
|
|
updated = client.put(
|
|
uploaded["url"],
|
|
headers={**headers, "Content-Type": "text/plain; charset=utf-8"},
|
|
content="edited text with accents: été".encode(),
|
|
)
|
|
assert updated.status_code == 200
|
|
assert updated.json()["size"] == len("edited text with accents: été".encode())
|
|
fetched = client.get(uploaded["url"])
|
|
assert fetched.status_code == 200
|
|
assert fetched.content == "edited text with accents: été".encode()
|
|
assert app.state.db.one("SELECT size FROM attachments WHERE id=?", (uploaded["id"],))["size"] == len(fetched.content)
|
|
client.cookies.clear()
|
|
assert client.put(uploaded["url"], content=b"forbidden").status_code == 401
|
|
|
|
|
|
def test_binary_attachment_cannot_be_edited_as_text(logged_in):
|
|
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
|
uploaded = client.post(
|
|
f"/api/conversations/{conversation['id']}/attachments",
|
|
headers=headers,
|
|
files={"image": ("archive.bin", b"\x00\x01\x02", "application/octet-stream")},
|
|
).json()
|
|
response = client.put(
|
|
uploaded["url"],
|
|
headers={**headers, "Content-Type": "text/plain"},
|
|
content=b"replacement",
|
|
)
|
|
assert response.status_code == 415
|
|
|
|
|
|
def test_oversized_text_edit_keeps_original_file(logged_in, monkeypatch):
|
|
client, _, _, conversation, headers = create_plain_conversation(logged_in)
|
|
uploaded = client.post(
|
|
f"/api/conversations/{conversation['id']}/attachments",
|
|
headers=headers,
|
|
files={"image": ("notes.txt", b"keep me", "text/plain")},
|
|
).json()
|
|
monkeypatch.setattr("codexpi.main.MAX_EDITABLE_TEXT_BYTES", 4)
|
|
response = client.put(uploaded["url"], headers=headers, content=b"12345")
|
|
assert response.status_code == 413
|
|
assert client.get(uploaded["url"]).content == b"keep me"
|
|
|
|
|
|
def test_messages_can_be_queued_while_codex_is_running(logged_in):
|
|
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
|
app.state.codex.codex_bin = sys.executable
|
|
app.state.db.execute("UPDATE conversations SET status='running' WHERE id=?", (conversation["id"],))
|
|
first = client.post(
|
|
f"/api/conversations/{conversation['id']}/queue", headers=headers, json={"content": "Second task"}
|
|
)
|
|
second = client.post(
|
|
f"/api/conversations/{conversation['id']}/queue", headers=headers, json={"content": "Third task"}
|
|
)
|
|
assert first.status_code == 202 and first.json()["status"] == "queued"
|
|
assert second.status_code == 202 and second.json()["position"] == 2
|
|
queued = client.get(f"/api/conversations/{conversation['id']}/queue").json()
|
|
assert [item["content"] for item in queued] == ["Second task", "Third task"]
|
|
cancelled = client.delete(f"/api/conversations/{conversation['id']}/queue/{first.json()['id']}", headers=headers)
|
|
assert cancelled.status_code == 200
|
|
|
|
|
|
def test_running_action_can_be_stopped_without_cancelling_waiting_messages(logged_in, monkeypatch):
|
|
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
|
stamp = "2026-09-18T12:00:00+00:00"
|
|
app.state.db.execute(
|
|
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at,started_at) VALUES(?,?,?,?,?,?)",
|
|
("running-task", conversation["id"], "wrong task", "running", stamp, stamp),
|
|
)
|
|
app.state.db.execute(
|
|
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)",
|
|
("next-task", conversation["id"], "keep this", "queued", stamp),
|
|
)
|
|
app.state.db.execute(
|
|
"UPDATE conversations SET status='running',worker_pid=4242 WHERE id=?",
|
|
(conversation["id"],),
|
|
)
|
|
monkeypatch.setattr(app.state.codex, "_worker_is_alive", lambda pid, conversation_id: True)
|
|
|
|
assert client.post(f"/api/conversations/{conversation['id']}/stop").status_code == 403
|
|
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='running-task'")["status"] == "running"
|
|
stopped = client.post(f"/api/conversations/{conversation['id']}/stop", headers=headers)
|
|
|
|
assert stopped.status_code == 200
|
|
assert stopped.json() == {"ok": True, "queue_id": "running-task"}
|
|
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='running-task'")["status"] == "cancelled"
|
|
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='next-task'")["status"] == "queued"
|
|
assert client.post(f"/api/conversations/{conversation['id']}/stop", headers=headers).status_code == 409
|
|
|
|
|
|
def test_orphaned_worker_is_released(logged_in, monkeypatch):
|
|
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
|
stamp = "2026-09-14T00:00:00+00:00"
|
|
app.state.db.execute(
|
|
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at,started_at) VALUES(?,?,?,?,?,?)",
|
|
("orphaned-task", conversation["id"], "stuck", "running", stamp, stamp),
|
|
)
|
|
app.state.db.execute(
|
|
"UPDATE conversations SET status='running',worker_pid=999999 WHERE id=?",
|
|
(conversation["id"],),
|
|
)
|
|
monkeypatch.setattr(app.state.codex, "_worker_is_alive", lambda pid, conversation_id: False)
|
|
|
|
assert app.state.codex.recover_orphaned_workers() == 1
|
|
task = app.state.db.one("SELECT status,finished_at,error FROM prompt_queue WHERE id='orphaned-task'")
|
|
restored = app.state.db.one("SELECT status,worker_pid FROM conversations WHERE id=?", (conversation["id"],))
|
|
assert task["status"] == "failed" and task["finished_at"] and "libérée" in task["error"]
|
|
assert restored == {"status": "failed", "worker_pid": None}
|
|
|
|
|
|
def test_waiting_messages_can_be_edited_and_cleared(logged_in):
|
|
client, app, _, conversation, headers = create_plain_conversation(logged_in)
|
|
app.state.codex.codex_bin = sys.executable
|
|
app.state.db.execute("UPDATE conversations SET status='running' WHERE id=?", (conversation["id"],))
|
|
first = client.post(
|
|
f"/api/conversations/{conversation['id']}/queue", headers=headers, json={"content": "Initial text"}
|
|
).json()
|
|
second = client.post(
|
|
f"/api/conversations/{conversation['id']}/queue", headers=headers, json={"content": "Another task"}
|
|
).json()
|
|
edited = client.put(
|
|
f"/api/conversations/{conversation['id']}/queue/{first['id']}",
|
|
headers=headers,
|
|
json={"content": "Edited text"},
|
|
)
|
|
assert edited.status_code == 200
|
|
queued = client.get(f"/api/conversations/{conversation['id']}/queue").json()
|
|
assert queued[0]["content"] == "Edited text"
|
|
messages = client.get(f"/api/conversations/{conversation['id']}/messages").json()
|
|
assert messages[0]["content"] == "Edited text"
|
|
assert messages[0]["metadata"]["queue_status"] == "queued"
|
|
cleared = client.delete(f"/api/conversations/{conversation['id']}/queue", headers=headers)
|
|
assert cleared.status_code == 200
|
|
assert cleared.json()["cancelled"] == 2
|
|
assert client.get(f"/api/conversations/{conversation['id']}/queue").json() == []
|
|
messages = client.get(f"/api/conversations/{conversation['id']}/messages").json()
|
|
assert all(message["metadata"]["queue_status"] == "cancelled" for message in messages)
|
|
|
|
|
|
def test_failed_message_exposes_copyable_error_details(logged_in):
|
|
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
|
stamp = "2026-10-15T08:44:00+00:00"
|
|
app.state.db.execute(
|
|
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at,started_at,finished_at,error) "
|
|
"VALUES(?,?,?,?,?,?,?,?)",
|
|
("failed-task", conversation["id"], "Build", "failed", stamp, stamp, stamp, "Selected model is at capacity."),
|
|
)
|
|
app.state.db.add_message(
|
|
conversation["id"], "user", "Build", {"queue_id": "failed-task"}
|
|
)
|
|
|
|
message = client.get(f"/api/conversations/{conversation['id']}/messages").json()[0]
|
|
|
|
assert message["metadata"]["queue_status"] == "failed"
|
|
assert message["metadata"]["queue_error"] == "Selected model is at capacity."
|
|
|
|
|
|
def test_large_action_outputs_are_compacted_only_in_the_messages_response(logged_in):
|
|
client, app, _, conversation, _ = create_plain_conversation(logged_in)
|
|
original_output = "first useful line\n" + ("x" * 20_000) + "\nlast useful line"
|
|
message_id = app.state.db.add_message(
|
|
conversation["id"],
|
|
"assistant",
|
|
"Done",
|
|
{
|
|
"actions": [
|
|
{
|
|
"id": "large-output",
|
|
"command": "build",
|
|
"output": original_output,
|
|
"status": "done",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
for index in range(4):
|
|
app.state.db.add_message(conversation["id"], "assistant", f"Recent answer {index}")
|
|
|
|
message = client.get(f"/api/conversations/{conversation['id']}/messages").json()[0]
|
|
action = message["metadata"]["actions"][0]
|
|
|
|
assert action["output"].startswith("first useful line")
|
|
assert action["output"].endswith("last useful line")
|
|
assert len(action["output"].encode("utf-8")) <= 2_048
|
|
assert action["output_truncated"] is True
|
|
assert action["output_original_bytes"] == len(original_output.encode("utf-8"))
|
|
|
|
stored = app.state.db.one("SELECT metadata FROM messages WHERE id=?", (message_id,))
|
|
assert json.loads(stored["metadata"])["actions"][0]["output"] == original_output
|
|
|
|
|
|
def test_conversation_can_branch_from_an_earlier_message(logged_in):
|
|
client, app, project, conversation, headers = create_plain_conversation(logged_in)
|
|
app.state.db.execute(
|
|
"UPDATE conversations SET model=?,reasoning_effort=? WHERE id=?",
|
|
("gpt-5.6-sol", "high", conversation["id"]),
|
|
)
|
|
first_id = app.state.db.add_message(conversation["id"], "user", "First request")
|
|
app.state.db.add_message(conversation["id"], "assistant", "First answer")
|
|
app.state.db.add_message(conversation["id"], "user", "Later request")
|
|
branched = client.post(
|
|
f"/api/conversations/{conversation['id']}/branch",
|
|
headers=headers,
|
|
json={"message_id": first_id},
|
|
)
|
|
assert branched.status_code == 201
|
|
branch = branched.json()
|
|
assert "bootstrap_context" not in branch
|
|
assert branch["parent_conversation_id"] == conversation["id"]
|
|
assert branch["forked_from_message_id"] == first_id
|
|
assert branch["model"] == "gpt-5.6-sol" and branch["reasoning_effort"] == "high"
|
|
copied = client.get(f"/api/conversations/{branch['id']}/messages").json()
|
|
assert [message["content"] for message in copied] == ["First request"]
|
|
stored = app.state.db.one("SELECT bootstrap_context FROM conversations WHERE id=?", (branch["id"],))
|
|
assert "First request" in stored["bootstrap_context"]
|
|
listed = client.get(f"/api/projects/{project['id']}/conversations").json()
|
|
assert all("bootstrap_context" not in item for item in listed)
|