From bdcddc1930bf925ea1d09268807f64fe730ebbab Mon Sep 17 00:00:00 2001 From: kimsungmin1011 Date: Mon, 14 Sep 2026 11:08:14 +0900 Subject: [PATCH] feat(chat): edit saved messages into independent conversations --- .github/workflows/ci.yml | 3 + apps/api/app/routers/sessions.py | 167 ++++- apps/api/app/schemas/chat.py | 7 + apps/api/app/services/files.py | 19 + apps/api/tests/test_edit_message_fork.py | 420 ++++++++++++ .../api/tests/test_edit_resend_integration.py | 250 +++++++ apps/web/e2e/edit-resend-message.spec.ts | 644 ++++++++++++++++++ apps/web/playwright.edit-resend.config.ts | 21 + apps/web/src/components/chat/Composer.tsx | 267 +++++++- apps/web/src/components/chat/MessageItem.tsx | 11 +- apps/web/src/lib/api.ts | 4 + apps/web/src/lib/i18n.ts | 8 + apps/web/src/store/useStore.ts | 102 ++- apps/web/src/types.ts | 2 + 14 files changed, 1901 insertions(+), 24 deletions(-) create mode 100644 apps/api/tests/test_edit_message_fork.py create mode 100644 apps/api/tests/test_edit_resend_integration.py create mode 100644 apps/web/e2e/edit-resend-message.spec.ts create mode 100644 apps/web/playwright.edit-resend.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f84208b..3fa6c870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,9 @@ jobs: - name: Test freshness policy feedback run: npx playwright test --config playwright.freshness.config.ts --workers=1 + - name: Test message editing and resend + run: npx playwright test --config playwright.edit-resend.config.ts --workers=1 + - name: Test Auto routing badge geometry run: npx playwright test --config playwright.auto-badge.config.ts --workers=1 diff --git a/apps/api/app/routers/sessions.py b/apps/api/app/routers/sessions.py index a3ea5845..7a678569 100644 --- a/apps/api/app/routers/sessions.py +++ b/apps/api/app/routers/sessions.py @@ -18,6 +18,7 @@ import re import uuid from collections.abc import AsyncIterator +from copy import deepcopy from dataclasses import dataclass from datetime import timedelta from typing import Any @@ -67,6 +68,7 @@ FigureSuggestion, FigureSuggestRequest, ImageRequest, + MessageForkOut, MessageOut, SendMessage, SessionBulkDelete, @@ -77,7 +79,7 @@ made_from_artifacts, snippet, ) -from app.schemas.workspace import ArtifactOut +from app.schemas.workspace import ArtifactOut, FileOut from app.services import ( adaptive_routing, artifact_extract, @@ -1218,6 +1220,169 @@ async def create_session(payload: SessionCreate, user: CurrentUser, db: DbSessio return SessionOut.of(session, []) +def _fork_attachment_ids(messages: list[Message]) -> list[str]: + ids = [] + seen = set() + for message in messages: + for metadata in message.attachments or []: + file_id = metadata.get("id") if isinstance(metadata, dict) else None + if not isinstance(file_id, str) or not file_id: + raise HTTPException(status_code=404, detail="attachment_not_found") + if file_id not in seen: + ids.append(file_id) + seen.add(file_id) + return ids + + +@router.post( + "/{session_id}/messages/{message_id}/fork", + response_model=MessageForkOut, + status_code=status.HTTP_201_CREATED, +) +async def fork_message(session_id: str, message_id: str, user: CurrentUser, db: DbSession): + """Copy only the prefix before a user message, without generation or prior consent.""" + source = ( + await db.exec( + select(ChatSession) + .where(ChatSession.id == session_id, ChatSession.user_id == user.id) + .with_for_update() + ) + ).first() + if source is None: + raise HTTPException(status_code=404, detail="session_not_found") + if source.kind is not SessionKind.chat: + raise HTTPException(status_code=422, detail="fork_chat_only") + history = await _history(db, source.id) + at = next((i for i, row in enumerate(history) if row.id == message_id), None) + if at is None: + raise HTTPException(status_code=404, detail="fork_target_not_found") + target = history[at] + if target.role is not Role.user: + raise HTTPException(status_code=422, detail="fork_user_message_required") + jobs = ( + await db.exec( + select(Job).where( + Job.session_id == source.id, col(Job.status).in_(["queued", "running"]) + ) + ) + ).all() + # The trailing unanswered question also catches a stream on another replica. + unanswered = history[-1].role is Role.user and history[-1].failure is None + if source.pending or _STOPPING.get(source.id) or jobs or unanswered: + raise HTTPException(status_code=409, detail="session_busy") + if "chat" not in await settings_store.enabled_kinds(): + raise HTTPException(status_code=403, detail="이 기능은 사용할 수 없습니다.") + await _validate_session_links( + db, user, source.kind, project_id=source.project_id, agent_id=source.agent_id + ) + + prefix = history[:at] + prefix_files = _fork_attachment_ids(prefix) + prefix_file_ids = set(prefix_files) + target_file_ids = _fork_attachment_ids([target]) + file_ids = list(dict.fromkeys([*prefix_files, *target_file_ids])) + uploads, _metadata = await _owned_attachments(db, user, file_ids) + artifact_ids = list(dict.fromkeys( + artifact_id for message in prefix for artifact_id in (message.artifact_ids or []) + )) + artifacts = ( + ( + await db.exec( + select(Artifact).where( + col(Artifact.id).in_(artifact_ids), Artifact.user_id == user.id + ) + ) + ).all() + if artifact_ids else [] + ) + if len(artifacts) != len(artifact_ids): + raise HTTPException(status_code=404, detail="artifact_not_found") + + # Bound the whole fork, not each file, before creating any row or blob. + copied_bytes = 0 + for item in [*uploads, *artifacts]: + if not item.storage_key: + continue + try: + copied_bytes += file_service.blob_size(item.storage_key) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="attachment_not_found") from None + if copied_bytes > settings.max_upload_mb * 1024 * 1024: + raise HTTPException( + status_code=413, detail=f"fork_files_too_large_{settings.max_upload_mb}mb" + ) + + branch = ChatSession( + user_id=user.id, kind=SessionKind.chat, + title=f"{source.title[:190]} (편집)" if source.title else "", + project_id=source.project_id, agent_id=source.agent_id, model=source.model, + routing_mode=source.routing_mode, render_template_id=source.render_template_id, + ) + copied_files: dict[str, StoredFile] = {} + copied_artifacts: dict[str, Artifact] = {} + copied_messages: list[Message] = [] + new_keys: list[str] = [] + try: + for stored in uploads: + copied = StoredFile( + user_id=user.id, session_id=branch.id if stored.id in prefix_file_ids else None, + name=stored.name, size=stored.size, mime=stored.mime, text=stored.text, + tokens=stored.tokens, error=stored.error, source_url=stored.source_url, + ) + if stored.storage_key: + copied.storage_key = file_service.copy_blob( + user.id, copied.id, copied.name, stored.storage_key + ) + new_keys.append(copied.storage_key) + copied_files[stored.id] = copied + for artifact in artifacts: + copied = Artifact( + user_id=user.id, session_id=branch.id, project_id=branch.project_id, + kind=artifact.kind, title=artifact.title, data=deepcopy(artifact.data), + ) + if artifact.storage_key: + copied.storage_key = file_service.copy_blob( + user.id, copied.id, copied.title or "artifact", artifact.storage_key + ) + new_keys.append(copied.storage_key) + copied_artifacts[artifact.id] = copied + prior_time = None + for message in prefix: + created = message.created_at + if prior_time is not None and created <= prior_time: + created = prior_time + timedelta(microseconds=1) + prior_time = created + attachments = [ + {**deepcopy(item), "id": copied_files[item["id"]].id} + for item in message.attachments or [] + ] or None + copied_messages.append(Message( + session_id=branch.id, role=message.role, content=message.content, + attachments=attachments, model=message.model, failure=message.failure, + started_from=deepcopy(message.started_from), created_at=created, + artifact_ids=[copied_artifacts[key].id for key in message.artifact_ids or []] + or None, + )) + output = MessageForkOut( + session=SessionOut.of(branch, copied_messages), + attachments=[FileOut.of(copied_files[key]) for key in target_file_ids], + attachment_id_map={key: copied_files[key].id for key in target_file_ids}, + ) + for row in [branch, *copied_files.values(), *copied_artifacts.values(), *copied_messages]: + db.add(row) + await db.commit() + except BaseException as exc: + try: + await db.rollback() + finally: + for key in new_keys: + file_service.delete_blob(key) + if isinstance(exc, FileNotFoundError): + raise HTTPException(status_code=404, detail="attachment_not_found") from None + raise + return output + + async def _template_skills( db: AsyncSession, template: design_templates.DesignTemplate | None, diff --git a/apps/api/app/schemas/chat.py b/apps/api/app/schemas/chat.py index 355e45f7..f54d1587 100644 --- a/apps/api/app/schemas/chat.py +++ b/apps/api/app/schemas/chat.py @@ -17,6 +17,7 @@ TurnFailure, ) from app.schemas.auth import Wire +from app.schemas.workspace import FileOut class MessageOut(Wire): @@ -299,6 +300,12 @@ class SessionCreate(Wire): routing_mode: RoutingMode = RoutingMode.manual +class MessageForkOut(Wire): + session: SessionOut + attachments: list[FileOut] + attachment_id_map: dict[str, str] + + class CompareRequest(Wire): content: str = Field(min_length=1) models: list[str] = Field(min_length=2, max_length=3) diff --git a/apps/api/app/services/files.py b/apps/api/app/services/files.py index 4b7639ab..c0de8735 100644 --- a/apps/api/app/services/files.py +++ b/apps/api/app/services/files.py @@ -65,6 +65,25 @@ def read_blob(key: str) -> bytes: return (storage_root() / key).read_bytes() +def blob_size(key: str) -> int: + """Size on disk, independent of the upload's recorded metadata.""" + return (storage_root() / key).stat().st_size + + +def copy_blob(user_id: str, file_id: str, name: str, source_key: str) -> str: + """Copy a stored blob to an independent key without re-reading it into memory.""" + root = storage_root() + key = f"{user_id}/{file_id}_{safe_name(name)}" + destination = root / key + destination.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copyfile(root / source_key, destination) + except BaseException: + destination.unlink(missing_ok=True) + raise + return key + + def delete_blob(key: str) -> None: try: (storage_root() / key).unlink(missing_ok=True) diff --git a/apps/api/tests/test_edit_message_fork.py b/apps/api/tests/test_edit_message_fork.py new file mode 100644 index 00000000..be35be56 --- /dev/null +++ b/apps/api/tests/test_edit_message_fork.py @@ -0,0 +1,420 @@ +"""Editing branches a saved prefix; it never rewrites or regenerates the original.""" + +import asyncio +import json +from copy import deepcopy +from datetime import timedelta + +import pytest +from fastapi import HTTPException + +from app.models.chat import ChatSession, Message, Role, SessionKind, TurnFailure +from app.models.governance import Governance +from app.models.user import User, utcnow +from app.models.workspace import Artifact, ArtifactKind, Job, StoredFile +from app.routers import sessions +from app.services import files + + +class _Result: + def __init__(self, rows): + self.rows = rows + + def all(self): + return self.rows + + def first(self): + return next(iter(self.rows), None) + + +class _Db: + def __init__(self, source, messages, *, uploads=(), artifacts=(), jobs=()): + self.source = source + self.messages = list(messages) + self.uploads = list(uploads) + self.artifacts = list(artifacts) + self.jobs = list(jobs) + self.added = [] + self.committed = [] + self.commits = 0 + self.rollbacks = 0 + self.fail_commit = False + + async def get(self, model, row_id): + rows = [self.source, *self.messages, *self.uploads, *self.artifacts, *self.committed] + return next((row for row in rows if isinstance(row, model) and row.id == row_id), None) + + async def exec(self, query): + table = query.get_final_froms()[0].name + params = query.compile().params + values = list(params.values()) + if table == "sessions": + assert query._for_update_arg is not None + row = self.source + return _Result([row] if row and row.id in values and row.user_id in values else []) + if table == "messages": + rows = [row for row in self.messages if row.session_id in values] + return _Result(sorted(rows, key=lambda row: (row.created_at, row.id))) + if table == "jobs": + return _Result([ + row for row in self.jobs + if row.session_id in values and row.status in ("queued", "running") + ]) + if table in ("files", "artifacts"): + ids = next((value for value in values if isinstance(value, list)), []) + rows = self.uploads if table == "files" else self.artifacts + return _Result([row for row in rows if row.id in ids and row.user_id in values]) + raise AssertionError(f"Unexpected query: {table}") + + def add(self, row): + self.added.append(row) + + async def commit(self): + if self.fail_commit: + raise RuntimeError("synthetic commit failure") + self.commits += 1 + self.committed.extend(self.added) + + async def rollback(self): + self.rollbacks += 1 + self.added.clear() + + +@pytest.fixture(autouse=True) +def _isolated(monkeypatch, tmp_path): + monkeypatch.setattr(files.settings, "file_storage_dir", str(tmp_path)) + monkeypatch.setattr(files.settings, "jwt_secret", "synthetic-message-edit-test-secret-20260914") + monkeypatch.setattr(sessions, "_STOPPING", {}) + + async def enabled(): + return ["chat"] + + monkeypatch.setattr(sessions.settings_store, "enabled_kinds", enabled) + + async def no_model(*_args, **_kwargs): + pytest.fail("Fork must not obtain credentials, call models or query a remote index") + + monkeypatch.setattr(sessions.model_service, "list_models_for_egress", no_model) + monkeypatch.setattr(sessions.litellm_service, "ensure_key", no_model) + monkeypatch.setattr(sessions.index_client, "forget_collection", no_model) + + +def _user(user_id="owner"): + return User( + id=user_id, email="synthetic@example.test", password_hash="unused", name="Synthetic" + ) + + +def _source(): + return ChatSession(id="source", user_id="owner", title="Original", model="synthetic-model") + + +def _history(count=3): + start = utcnow() - timedelta(days=1) + return [ + Message( + id=f"m{i}", session_id="source", role=Role.user if i % 2 == 0 else Role.assistant, + content=f"saved-{i}", created_at=start + timedelta(seconds=i), + ) + for i in range(count * 2) + ] + + +def _upload(file_id, *, session_id="source", user_id="owner"): + row = StoredFile( + id=file_id, user_id=user_id, session_id=session_id, name=f"{file_id}.txt", + mime="text/plain", size=4, text=f"extracted {file_id}", tokens=4, indexed_at=utcnow(), + ) + row.storage_key = files.write_blob(user_id, file_id, row.name, b"body") + return row + + +async def _fork(db, target="m2", user=None): + return await sessions.fork_message("source", target, user or _user(), db) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target,prefix_length", [("m0", 0), ("m2", 2), ("m4", 4)]) +async def test_first_middle_and_latest_user_create_a_new_prefix(target, prefix_length): + source, history = _source(), _history() + before = deepcopy([source.model_dump(), *[row.model_dump() for row in history]]) + db = _Db(source, history) + result = await _fork(db, target) + assert result.session.id != source.id + assert result.session.model == source.model + assert [m.content for m in result.session.messages] == [ + m.content for m in history[:prefix_length] + ] + assert not set(m.id for m in result.session.messages) & set(m.id for m in history) + assert result.attachments == [] + assert db.commits == 1 + assert before == [source.model_dump(), *[row.model_dump() for row in history]] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source_user,target,code", [ + ("other", "m2", 404), ("owner", "absent", 404), ("owner", "m1", 422), +]) +async def test_fork_target_validation_happens_before_any_write(source_user, target, code): + source = _source() + source.user_id = source_user + db = _Db(source, _history()) + with pytest.raises(HTTPException) as error: + await _fork(db, target) + assert error.value.status_code == code + assert db.added == [] and db.commits == 0 + + +@pytest.mark.asyncio +async def test_absent_session_and_target_from_another_session_are_not_found(): + for source, history in [(None, _history()), (_source(), _history())]: + if source: + history[2].session_id = "another" + db = _Db(source, history) + with pytest.raises(HTTPException) as error: + await _fork(db) + assert error.value.status_code == 404 + assert db.added == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("busy", ["local", "pending", "job", "unanswered"]) +async def test_running_or_pending_source_is_refused_without_canceling_it(busy): + source, history = _source(), _history() + jobs = [] + signal = asyncio.Event() + if busy == "local": + sessions._STOPPING[source.id] = {signal} + elif busy == "pending": + source.pending = {"request": "must not copy"} + elif busy == "job": + jobs.append(Job(user_id="owner", session_id=source.id, status="running")) + else: + history.pop() + db = _Db(source, history, jobs=jobs) + with pytest.raises(HTTPException) as error: + await _fork(db) + assert error.value.status_code == 409 + assert error.value.detail == "session_busy" + assert db.added == [] and not signal.is_set() + + +@pytest.mark.asyncio +async def test_failed_latest_user_can_be_edited_and_tied_times_stay_ordered(): + source, history = _source(), _history() + history.pop() + history[-1].failure = TurnFailure.no_answer + for message in history: + message.created_at = history[0].created_at + db = _Db(source, history) + result = await _fork(db, "m4") + cloned = [row for row in db.added if isinstance(row, Message)] + assert [row.content for row in sorted(cloned, key=lambda m: (m.created_at, m.id))] == [ + m.content for m in history[:4] + ] + branch = next(row for row in db.added if isinstance(row, ChatSession)) + assert branch.pending is None and branch.id == result.session.id + + +@pytest.mark.asyncio +async def test_only_prefix_and_target_files_are_independent_copies(): + source, history = _source(), _history() + prefix, target, future = (_upload(name) for name in ["prefix", "target", "future"]) + history[0].attachments = [{"id": prefix.id, "name": prefix.name}] + history[2].attachments = [{"id": target.id, "name": target.name}] + history[4].attachments = [{"id": future.id, "name": future.name}] + original = deepcopy([row.model_dump() for row in [prefix, target, future]]) + db = _Db(source, history, uploads=[prefix, target, future]) + result = await _fork(db) + clones = [row for row in db.added if isinstance(row, StoredFile)] + assert len(clones) == 2 + assert {row.text for row in clones} == {prefix.text, target.text} + copied_prefix = next(row for row in clones if row.text == prefix.text) + copied_target = next(row for row in clones if row.text == target.text) + assert copied_prefix.session_id == result.session.id + assert copied_target.session_id is None + assert result.session.messages[0].attachments[0]["id"] == copied_prefix.id + assert [row.id for row in result.attachments] == [copied_target.id] + assert result.attachment_id_map == {target.id: copied_target.id} + assert all( + row.indexed_at is None and row.project_id is None and row.agent_id is None for row in clones + ) + for row in [prefix, target, future]: + files.delete_blob(row.storage_key) + assert all(files.read_blob(row.storage_key) == b"body" for row in clones) + assert original == [row.model_dump() for row in [prefix, target, future]] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("missing", ["foreign", "row", "blob"]) +async def test_unavailable_attachment_fails_without_partial_branch(missing): + source, history = _source(), _history() + first = _upload("first") + second = _upload("second", user_id="other" if missing == "foreign" else "owner") + history[0].attachments = [{"id": first.id}] + history[2].attachments = [{"id": second.id}] + if missing == "blob": + files.delete_blob(second.storage_key) + db = _Db(source, history, uploads=[first] if missing == "row" else [first, second]) + before_paths = {str(path) for path in files.storage_root().rglob("*") if path.is_file()} + with pytest.raises(HTTPException) as error: + await _fork(db) + assert error.value.status_code == 404 + assert {str(path) for path in files.storage_root().rglob("*") if path.is_file()} == before_paths + assert db.commits == 0 and db.added == [] + + +@pytest.mark.asyncio +async def test_commit_failure_removes_all_new_blobs_and_leaves_original_rows(): + source, history = _source(), _history() + uploaded = _upload("original") + history[2].attachments = [{"id": uploaded.id}] + db = _Db(source, history, uploads=[uploaded]) + db.fail_commit = True + before = deepcopy([source.model_dump(), uploaded.model_dump()]) + with pytest.raises(RuntimeError, match="synthetic"): + await _fork(db) + assert db.rollbacks == 1 and db.added == [] + paths = [str(path.relative_to(files.storage_root())) + for path in files.storage_root().rglob("*") if path.is_file()] + assert paths == [uploaded.storage_key] + assert before == [source.model_dump(), uploaded.model_dump()] + + +@pytest.mark.asyncio +async def test_prefix_artifact_snapshot_survives_original_blob_and_row_deletion(): + source, history = _source(), _history() + artifact = Artifact( + id="artifact-original", user_id="owner", session_id=source.id, kind=ArtifactKind.code, + title="snapshot", data={"content": "saved code", "language": "python"}, version=3, + ) + artifact.storage_key = files.write_blob("owner", artifact.id, "saved.txt", b"snapshot") + history[1].artifact_ids = [artifact.id] + history[1].usage = {"credits": 10} + history[1].variants = [{"content": "alternative"}] + history[1].steps = [{"type": "approval", "token": "synthetic-old-approval"}] + source.artifact_id = artifact.id + source.index_key = "original-index" + db = _Db(source, history, artifacts=[artifact]) + result = await _fork(db) + copied = next(row for row in db.added if isinstance(row, Artifact)) + assert copied.id != artifact.id and copied.session_id == result.session.id + assert copied.data == artifact.data and copied.data is not artifact.data + assert copied.version == 1 + files.delete_blob(artifact.storage_key) + db.artifacts.clear() + assert files.read_blob(copied.storage_key) == b"snapshot" + assert result.session.messages[1].artifact_ids == [copied.id] + assert result.session.messages[1].steps is None + assert result.session.messages[1].usage is None + assert result.session.messages[1].variants is None + clone_session = next(row for row in db.added if isinstance(row, ChatSession)) + assert clone_session.artifact_id is None and clone_session.index_key is None + + +@pytest.mark.asyncio +async def test_foreign_artifact_is_not_copied_and_nonchat_is_not_forked(): + source, history = _source(), _history() + artifact = Artifact(id="a", user_id="other", kind=ArtifactKind.code, data={"content": "other"}) + history[1].artifact_ids = [artifact.id] + db = _Db(source, history, artifacts=[artifact]) + with pytest.raises(HTTPException) as error: + await _fork(db) + assert error.value.status_code == 404 and db.added == [] + source.kind = SessionKind.report + with pytest.raises(HTTPException) as error: + await _fork(db) + assert error.value.status_code == 422 and db.added == [] + + +@pytest.mark.asyncio +async def test_partial_blob_copy_failure_removes_destination_but_not_source(monkeypatch): + source, history = _source(), _history() + uploaded = _upload("original") + history[2].attachments = [{"id": uploaded.id}] + db = _Db(source, history, uploads=[uploaded]) + + def incomplete(_source, destination): + destination.write_bytes(b"partial") + raise OSError("synthetic disk failure") + + monkeypatch.setattr(files.shutil, "copyfile", incomplete) + with pytest.raises(OSError, match="synthetic"): + await _fork(db) + assert files.read_blob(uploaded.storage_key) == b"body" + paths = [path for path in files.storage_root().rglob("*") if path.is_file()] + assert paths == [files.storage_root() / uploaded.storage_key] + assert db.added == [] and db.commits == 0 + + +@pytest.mark.asyncio +async def test_source_privacy_consent_cannot_be_replayed_in_the_new_branch(): + from test_privacy import _external_model + + source, history = _source(), _history() + user = _user() + db = _Db(source, history) + result = await _fork(db) + branch = next(row for row in db.added if isinstance(row, ChatSession)) + model = _external_model("synthetic/model") + policy = Governance(external_data_guard=True) + sources = {"message": "연락처: synthetic-person@example.test"} + first = await sessions._resolve_privacy( + user=user, session=source, policy=policy, catalogue=[model], requested=[model], + sources=sources, explicit_action=None, decision_token=None, + ) + assert first.status_code == 409 + old_token = json.loads(first.body)["decisionToken"] + retried = await sessions._resolve_privacy( + user=user, session=branch, policy=policy, catalogue=[model], requested=[model], + sources=sources, explicit_action="mask_external", decision_token=old_token, + ) + assert retried.status_code == 409 + new_token = json.loads(retried.body)["decisionToken"] + assert new_token != old_token + accepted = await sessions._resolve_privacy( + user=user, session=branch, policy=policy, catalogue=[model], requested=[model], + sources=sources, explicit_action="mask_external", decision_token=new_token, + ) + assert accepted.action == "mask_external" + assert result.session.id == branch.id + + +@pytest.mark.asyncio +async def test_one_file_shared_by_prefix_and_target_is_copied_once(): + source, history = _source(), _history() + shared = _upload("shared") + history[0].attachments = [{"id": shared.id}] + history[2].attachments = [{"id": shared.id}, {"id": shared.id}] + db = _Db(source, history, uploads=[shared]) + result = await _fork(db) + copies = [row for row in db.added if isinstance(row, StoredFile)] + assert len(copies) == 1 + assert copies[0].session_id == result.session.id + assert result.attachment_id_map == {shared.id: copies[0].id} + assert len(result.attachments) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("oversized", ["file", "combined", "artifact"]) +async def test_actual_total_blob_size_is_bounded_before_any_write(monkeypatch, oversized): + source, history = _source(), _history() + uploaded = _upload("original") + history[2].attachments = [{"id": uploaded.id}] + artifact = Artifact(id="original-artifact", user_id="owner", kind=ArtifactKind.code) + artifact.storage_key = files.write_blob("owner", artifact.id, "original.txt", b"body") + history[1].artifact_ids = [artifact.id] + # Deliberately understate StoredFile.size: only actual blob size is authoritative. + file_size = 1024 * 1024 + 1 if oversized == "file" else 600 * 1024 + artifact_size = 1024 * 1024 + 1 if oversized == "artifact" else 600 * 1024 + (files.storage_root() / uploaded.storage_key).write_bytes(b"f" * file_size) + (files.storage_root() / artifact.storage_key).write_bytes(b"a" * artifact_size) + monkeypatch.setattr(sessions.settings, "max_upload_mb", 1) + db = _Db(source, history, uploads=[uploaded], artifacts=[artifact]) + before = {str(path) for path in files.storage_root().rglob("*") if path.is_file()} + with pytest.raises(HTTPException) as error: + await _fork(db) + assert error.value.status_code == 413 + assert error.value.detail == "fork_files_too_large_1mb" + assert db.added == [] and db.commits == 0 + assert {str(path) for path in files.storage_root().rglob("*") if path.is_file()} == before diff --git a/apps/api/tests/test_edit_resend_integration.py b/apps/api/tests/test_edit_resend_integration.py new file mode 100644 index 00000000..5fa90122 --- /dev/null +++ b/apps/api/tests/test_edit_resend_integration.py @@ -0,0 +1,250 @@ +"""Real HTTP/SQLite fork and resend boundaries; generation and authentication are synthetic.""" + +from datetime import timedelta + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import JSON, MetaData +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import SQLModel, select +from sqlmodel.ext.asyncio.session import AsyncSession +from test_privacy import _external_model, _patch_guard_dependencies + +from app.core.db import get_session +from app.core.deps import current_user +from app.models.chat import ChatSession, Message, Role +from app.models.user import AuditEvent, User, utcnow +from app.models.workspace import Agent, Artifact, Job, StoredFile +from app.routers import sessions +from app.services import files + + +@pytest.fixture +async def scenario(monkeypatch, tmp_path): + metadata = MetaData() + for table in SQLModel.metadata.sorted_tables: + copied = table.to_metadata(metadata) + for column in copied.columns: + if isinstance(column.type, JSONB): + column.type = JSON() + column.server_default = None + engine = create_async_engine("sqlite+aiosqlite://") + async with engine.begin() as connection: + await connection.run_sync( + metadata.create_all, + tables=[ + metadata.tables[model.__tablename__] + for model in ( + User, + Agent, + ChatSession, + Message, + StoredFile, + Artifact, + Job, + AuditEvent, + ) + ], + ) + monkeypatch.setattr(files.settings, "file_storage_dir", str(tmp_path)) + monkeypatch.setattr(sessions, "_STOPPING", {}) + user = User(id="owner", email="owner@example.test", name="Fixture", password_hash="unused") + other = User(id="other", email="other@example.test", name="Other", password_hash="unused") + source = ChatSession(id="source", user_id=user.id, title="Source", model="fixture/model") + now = utcnow() - timedelta(minutes=1) + messages = [ + Message( + id=f"message-{index}", + session_id=source.id, + role=Role.user if index % 2 == 0 else Role.assistant, + content=content, + created_at=now + timedelta(seconds=index), + ) + for index, content in enumerate( + [ + "Prefix question.", + "Prefix answer.", + "Original selected question.", + "Old answer that must not be reused.", + "Later question excluded.", + "Later answer excluded.", + ] + ) + ] + upload = StoredFile( + id="original-file", + user_id=user.id, + session_id=source.id, + name="notes.txt", + mime="text/plain", + size=12, + text="Fixture note", + ) + upload.storage_key = files.write_blob(user.id, upload.id, upload.name, b"Fixture note") + messages[2].attachments = [{"id": upload.id, "name": upload.name, "size": upload.size}] + async with AsyncSession(engine, expire_on_commit=False) as db: + db.add_all([user, other, source, *messages, upload]) + await db.commit() + real_owned, real_history = sessions._owned, sessions._history + await _patch_guard_dependencies( + monkeypatch, + session=source, + models=[_external_model("fixture/model")], + blocks=[], + ) + monkeypatch.setattr(sessions, "_owned", real_owned) + monkeypatch.setattr(sessions, "_history", real_history) + + async def enabled(): + return ["chat"] + + async def key(*args, **kwargs): + return "fixture-only" + + async def credentials(*args, **kwargs): + return "http://fixture.invalid", "fixture-only" + + async def no_tools(*args, **kwargs): + return [] + + calls = [] + + async def run(**kwargs): + calls.append(kwargs) + yield sessions.chat_service.sse({"type": "delta", "text": "Synthetic answer"}) + yield sessions.chat_service.sse({"type": "usage", "credits": 0}) + yield sessions.chat_service.sse({"type": "done"}) + + monkeypatch.setattr(sessions.settings_store, "enabled_kinds", enabled) + monkeypatch.setattr(sessions, "has_headroom", lambda *args: True) + monkeypatch.setattr(sessions.litellm_service, "ensure_key", key) + monkeypatch.setattr(sessions.litellm_service, "credentials_for", credentials) + monkeypatch.setattr(sessions, "build_tools", no_tools) + monkeypatch.setattr(sessions, "_run_turn", run) + + async def db_session(): + async with AsyncSession(engine, expire_on_commit=False) as db: + yield db + + app = FastAPI() + app.include_router(sessions.router) + app.dependency_overrides[get_session] = db_session + app.dependency_overrides[current_user] = lambda: user + try: + async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: + yield client, engine, source, messages, upload, calls, app, other + finally: + await engine.dispose() + + +async def _snapshot(engine, session_id): + async with AsyncSession(engine) as db: + messages = ( + await db.exec( + select(Message) + .where(Message.session_id == session_id) + .order_by(Message.created_at, Message.id) + ) + ).all() + return [row.model_dump(mode="json") for row in messages] + + +async def test_http_fork_wire_shape_and_database_preserve_original(scenario): + client, engine, source, messages, upload, calls, _, _ = scenario + before = await _snapshot(engine, source.id) + response = await client.post(f"/sessions/{source.id}/messages/{messages[2].id}/fork") + assert response.status_code == 201 + body = response.json() + branch = body["session"] + clone_id = body["attachmentIdMap"][upload.id] + assert clone_id != upload.id + assert body["attachments"][0]["id"] == clone_id + assert body["attachments"][0]["sessionId"] is None + assert [row["content"] for row in branch["messages"]] == [row.content for row in messages[:2]] + assert await _snapshot(engine, source.id) == before + assert calls == [] + async with AsyncSession(engine) as db: + stored = await db.get(ChatSession, branch["id"]) + clone = await db.get(StoredFile, clone_id) + original = await db.get(StoredFile, upload.id) + assert stored.user_id == source.user_id + assert stored.pending is None and stored.artifact_id is None and stored.index_key is None + assert original.session_id == source.id + assert clone.storage_key != original.storage_key + assert files.read_blob(clone.storage_key) == files.read_blob(original.storage_key) + + +async def test_resend_uses_edited_content_prefix_and_cloned_attachment(scenario): + client, engine, source, messages, upload, calls, _, _ = scenario + before = await _snapshot(engine, source.id) + fork = (await client.post(f"/sessions/{source.id}/messages/{messages[2].id}/fork")).json() + branch_id = fork["session"]["id"] + clone_id = fork["attachmentIdMap"][upload.id] + response = await client.post( + f"/sessions/{branch_id}/messages", + json={ + "content": "Explain the edited subject briefly.", + "attachments": [clone_id], + "webSearch": False, + }, + ) + assert response.status_code == 200 + assert len(calls) == 1 + outbound = str(calls[0]["messages"]) + assert "Prefix question." in outbound and "Prefix answer." in outbound + assert "Explain the edited subject briefly." in outbound + assert all(row.content not in outbound for row in messages[2:]) + assert await _snapshot(engine, source.id) == before + saved = await _snapshot(engine, branch_id) + assert saved[-1]["content"] == "Explain the edited subject briefly." + assert saved[-1]["attachments"][0]["id"] == clone_id + async with AsyncSession(engine) as db: + assert (await db.get(StoredFile, clone_id)).session_id == branch_id + assert (await db.get(StoredFile, upload.id)).session_id == source.id + + +async def test_fork_requires_new_privacy_decision_and_masks_the_new_turn(scenario): + client, engine, source, messages, _, calls, _, _ = scenario + text = "Please rewrite this address politely: person@example.com" + original_decision = await client.post(f"/sessions/{source.id}/messages", json={"content": text}) + assert original_decision.status_code == 409 + old_token = original_decision.json()["decisionToken"] + fork = (await client.post(f"/sessions/{source.id}/messages/{messages[2].id}/fork")).json() + branch_id = fork["session"]["id"] + before = await _snapshot(engine, branch_id) + replay = await client.post( + f"/sessions/{branch_id}/messages", + json={ + "content": text, + "privacyAction": "mask_external", + "privacyDecisionToken": old_token, + }, + ) + assert 400 <= replay.status_code < 500 + assert await _snapshot(engine, branch_id) == before and calls == [] + decision = await client.post(f"/sessions/{branch_id}/messages", json={"content": text}) + assert decision.status_code == 409 + assert await _snapshot(engine, branch_id) == before + allowed = await client.post( + f"/sessions/{branch_id}/messages", + json={ + "content": text, + "privacyAction": "mask_external", + "privacyDecisionToken": decision.json()["decisionToken"], + }, + ) + assert allowed.status_code == 200 and len(calls) == 1 + assert "person@example.com" not in str(calls[0]["messages"]) + assert "person@example.com" not in str(await _snapshot(engine, branch_id)) + + +async def test_http_foreign_user_cannot_fork_or_create_files(scenario): + client, engine, source, messages, _, calls, app, other = scenario + app.dependency_overrides[current_user] = lambda: other + response = await client.post(f"/sessions/{source.id}/messages/{messages[2].id}/fork") + assert response.status_code == 404 and calls == [] + async with AsyncSession(engine) as db: + assert len((await db.exec(select(ChatSession))).all()) == 1 + assert len((await db.exec(select(StoredFile))).all()) == 1 diff --git a/apps/web/e2e/edit-resend-message.spec.ts b/apps/web/e2e/edit-resend-message.spec.ts new file mode 100644 index 00000000..2989b245 --- /dev/null +++ b/apps/web/e2e/edit-resend-message.spec.ts @@ -0,0 +1,644 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test' + +test.use({ serviceWorkers: 'block' }) + +const sourceId = '11111111111111111111111111111111' +const otherId = '22222222222222222222222222222222' +const forkId = '33333333333333333333333333333333' +const fileId = '44444444444444444444444444444444' +const cloneFileId = '55555555555555555555555555555555' +const addedFileId = '66666666666666666666666666666666' +const duplicateFileId = '77777777777777777777777777777777' +const duplicateCloneId = '88888888888888888888888888888888' +const at = '2026-09-14T00:00:00.000Z' +const prompts = ['첫 번째 개념을 알려줘.', '두 번째 설명을 짧게 해줘.', '마지막 예시를 알려줘.'] +const answers = ['첫 번째 답변입니다.', '두 번째 답변입니다.', '마지막 답변입니다.'] +const edited = '두 번째 설명을 세 문장으로 고쳐줘.' +const freshAnswer = '수정된 요청에 대한 새로운 답변입니다.' +const userIds = ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3'] +type Row = Record +type Write = { method: string; path: string; data: Row } + +function deferred() { + let release!: () => void + const promise = new Promise((resolve) => { release = resolve }) + return { promise, release } +} + +function message(id: string, role: string, content: string, attachments: Row[] = []) { + return { id, role, content, attachments, createdAt: at, steps: null, variants: null, + usage: null, model: role === 'assistant' ? 'fixture/model' : null, + routing: null, startedFrom: null, rating: null, artifactIds: null, failure: null } +} + +async function fixture(page: Page, info: TestInfo, options: { + attachments?: boolean; privacy?: boolean; holdFork?: boolean; holdSend?: boolean; + failFork?: boolean; failSend?: boolean; streamResponses?: boolean; skill?: boolean; + duplicateAttachments?: boolean; failSendStored?: boolean; failRecoveryLookup?: boolean; + holdPostSendList?: boolean; +} = {}) { + const origin = new URL(String(info.project.use.baseURL)).origin + expect(new URL(origin).hostname).toBe('127.0.0.1') + const forkGate = deferred() + const sendGate = deferred() + const listGate = deferred() + if (!options.holdFork) forkGate.release() + if (!options.holdSend) sendGate.release() + if (!options.holdPostSendList) listGate.release() + const originalFile = { id: fileId, name: 'notes.txt', mime: 'text/plain', type: 'text/plain', + size: 32, tokens: 8, projectId: null, sessionId: sourceId, createdAt: at, + preview: 'Synthetic attachment context.', error: null } + const cloneFile = { ...originalFile, id: cloneFileId, sessionId: null } + const duplicateFile = { ...originalFile, id: duplicateFileId, preview: 'A distinct file with the same name.' } + const duplicateClone = { ...duplicateFile, id: duplicateCloneId, sessionId: null } + const addedFile = { ...originalFile, id: addedFileId, name: 'additional.txt', sessionId: null } + const messages = prompts.flatMap((prompt, index) => [ + message(userIds[index], 'user', prompt, options.attachments && index === 1 + ? [originalFile, ...(options.duplicateAttachments ? [duplicateFile] : [])] : []), + message(`bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb${index + 1}`, 'assistant', answers[index]), + ]) + const source = { id: sourceId, title: '원본 대화', kind: 'chat', model: 'fixture/model', + routingMode: 'manual', projectId: null, agentId: null, artifactId: null, pinned: false, + messages, messageCount: messages.length, made: null, createdAt: at, updatedAt: at } + const other = { ...source, id: otherId, title: '다른 대화', messages: [], messageCount: 0 } + const rows = new Map([[sourceId, source], [otherId, other]]) + const original = JSON.stringify(source) + const writes: Write[] = [] + const unexpected: string[] = [] + const pageErrors: string[] = [] + let forksFailed = 0 + let sendsFailed = 0 + let heldLists = 0 + let principal: 'a' | 'b' | null = 'a' + const authSession = () => ({ + accessToken: 'fixture-only', expiresIn: 3600, + user: { id: principal === 'b' ? 'fixture-user-b' : 'fixture-user', + name: principal === 'b' ? 'Other fixture' : 'Edit fixture', + email: principal === 'b' ? 'other@example.test' : 'fixture@example.test', role: 'user', + status: 'active', monthlyCredits: 1000, creditsUsed: 0, avatarColor: '#168267', + allowedModels: [], createdAt: at, preferences: { autoMemory: false, showUsage: false, + streamResponses: options.streamResponses ?? true } }, + }) + const forkCalls = () => writes.filter((write) => write.path.endsWith('/fork')) + const sendCalls = () => writes.filter((write) => write.path.endsWith('/messages')) + const decision = { code: 'privacy_decision_required', + findings: [{ category: 'email', source: 'current_input', count: 1 }], + requestedModels: ['fixture/model'], safeModels: [], allowedActions: ['mask_external', 'edit', 'cancel'], + decisionToken: 'fixture-bound-to-fork', detectorVersion: 'privacy-detector-v1', + policyVersion: 'external-data-guard-v1' } + page.on('pageerror', (error) => pageErrors.push(error.message)) + await page.context().routeWebSocket('**/*', (socket) => { unexpected.push('WebSocket'); socket.close() }) + await page.context().route('**/*', async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (url.origin !== origin) { + unexpected.push(`external ${url.origin}`) + return route.abort('blockedbyclient') + } + if (!url.pathname.startsWith('/api/')) return route.continue() + const path = url.pathname.slice(4) + const method = request.method() + if (method === 'POST' && path === '/auth/refresh') return route.fulfill(principal + ? { json: authSession() } : { status: 401, json: { detail: 'not_authenticated' } }) + if (method === 'POST' && path === '/auth/logout') { + principal = null + return route.fulfill({ status: 204 }) + } + if (method === 'POST' && path === '/auth/login') { + principal = 'b' + return route.fulfill({ json: authSession() }) + } + if (method === 'GET' && path === '/auth/config') return route.fulfill({ json: { + brand: { name: 'KloudChat', logo: '' }, enabledKinds: ['chat'], + privacy: { externalDataGuard: !!options.privacy }, passwordResetEnabled: false, dictationEnabled: false, + } }) + if (method === 'GET' && path === '/models') return route.fulfill({ json: { + models: [{ id: 'fixture/model', label: 'Fixture', name: 'Fixture', vendor: 'Fixture', + provider: 'fixture', kinds: ['chat'], modality: 'chat', dataBoundary: 'external', + creditCost: 1, inputCreditCost: 1, supportsTools: true, contextWindow: 64000 }], + defaultChatModel: 'fixture/model', defaults: { chat: 'fixture/model' }, + litellmAvailable: true, autoRouting: { enabled: false, available: false }, + } }) + if (method === 'GET' && path === '/credits') return route.fulfill({ json: { + monthlyCredits: 1000, creditsUsed: 0, creditsRemaining: 1000, + } }) + if (method === 'GET' && path === '/skills') return route.fulfill({ json: options.skill ? [{ + id: 'fixture-skill', name: '초안 검토', slug: 'draft-review', description: 'Synthetic style only', + whenToUse: '', body: 'Preserve the supplied facts.', catalogKey: null, source: 'custom', + kinds: ['chat'], requiredTools: [], estimatedTokens: 10, version: '1', enabled: true, + visibility: 'private', installs: 0, originId: null, updatedAt: at, + }] : [] }) + if (method === 'POST' && path === '/files') { + writes.push({ method, path, data: { multipart: request.postData() ?? '' } }) + return route.fulfill({ status: 201, json: addedFile }) + } + if (method === 'GET' && path === '/sessions') { + const snapshot = structuredClone(principal === 'b' ? [other] : [...rows.values()]) + if (options.holdPostSendList && principal === 'a' && sendCalls().length > 0) { + heldLists++ + await listGate.promise + } + return route.fulfill({ json: snapshot }) + } + const sessionMatch = path.match(/^\/sessions\/([^/]+)$/) + if (method === 'GET' && sessionMatch) { + if (options.failRecoveryLookup && sendsFailed > 0 && sessionMatch[1] === forkId) { + return route.fulfill({ status: 503, json: { detail: 'Synthetic lookup unavailable' } }) + } + const row = principal === 'b' && sessionMatch[1] !== otherId ? undefined : rows.get(sessionMatch[1]) + return route.fulfill(row ? { json: row } : { status: 404, json: { detail: 'not_found' } }) + } + const forkMatch = path.match(/^\/sessions\/([^/]+)\/messages\/([^/]+)\/fork$/) + if (method === 'POST' && forkMatch) { + writes.push({ method, path, data: request.postData() ? request.postDataJSON() : {} }) + await forkGate.promise + if (options.failFork && forksFailed++ === 0) { + return route.fulfill({ status: 503, json: { detail: 'Synthetic fork unavailable' } }) + } + if (forkMatch[1] !== sourceId || !userIds.includes(forkMatch[2])) { + unexpected.push(`invalid fork ${path}`) + return route.fulfill({ status: 404, json: { detail: 'not_found' } }) + } + const target = messages.findIndex((entry) => entry.id === forkMatch[2]) + const prefix = messages.slice(0, target).map((entry, index) => ({ ...entry, id: (index + 16).toString(16).padStart(32, '0') })) + const fork = { ...source, id: forkId, title: '수정된 대화', messages: prefix, messageCount: prefix.length } + rows.set(forkId, fork) + const clones = options.attachments && forkMatch[2] === userIds[1] + ? [cloneFile, ...(options.duplicateAttachments ? [duplicateClone] : [])] : [] + return route.fulfill({ json: { session: fork, attachments: clones, + attachmentIdMap: clones.length ? { [fileId]: cloneFileId, + ...(options.duplicateAttachments ? { [duplicateFileId]: duplicateCloneId } : {}) } : {} } }) + } + const sendMatch = path.match(/^\/sessions\/([^/]+)\/messages$/) + if (method === 'POST' && sendMatch) { + const data = request.postDataJSON() as Row + writes.push({ method, path, data }) + await sendGate.promise + if (options.failSend && sendsFailed++ === 0) { + if (options.failSendStored) { + const row = rows.get(sendMatch[1])! + row.messages = [...row.messages, message('cccccccccccccccccccccccccccccccc', 'user', String(data.content))] + row.messageCount = row.messages.length + } + return route.fulfill({ status: 503, json: { detail: 'Synthetic send unavailable' } }) + } + if (options.privacy && data.privacyAction !== 'mask_external') { + return route.fulfill({ status: 409, json: { ...decision, + decisionToken: sendCalls().length > 1 ? 'fixture-revised-envelope' : decision.decisionToken } }) + } + const row = rows.get(sendMatch[1]) + if (!row) return route.fulfill({ status: 404, json: { detail: 'not_found' } }) + const files = [cloneFile, duplicateClone, addedFile].filter((file) => (data.attachments as string[] | undefined)?.includes(file.id)) + row.messages = [...row.messages, message('cccccccccccccccccccccccccccccccc', 'user', String(data.content), files), + message('dddddddddddddddddddddddddddddddd', 'assistant', freshAnswer)] + row.messageCount = row.messages.length + return route.fulfill({ contentType: 'text/event-stream', body: [ + { type: 'delta', text: freshAnswer }, + { type: 'usage', inputTokens: 8, outputTokens: 8, credits: 0 }, { type: 'done' }, + ].map((event) => `data: ${JSON.stringify(event)}\n\n`).join('') }) + } + if (method === 'GET' && path === '/artifacts/counts') return route.fulfill({ json: { counts: {}, total: 0 } }) + if (method === 'GET' && (path.endsWith('/jobs') || ['/projects', '/skills', '/memory', '/tools', + '/templates', '/connectors', '/connectors/catalog', '/designs', '/design-templates', + '/prompt-templates', '/shares', '/artifacts', '/agents'].includes(path))) { + return route.fulfill({ json: [] }) + } + unexpected.push(`${method} ${path}`) + if (method !== 'GET') writes.push({ method, path, data: request.postData() ? request.postDataJSON() : {} }) + return route.abort('blockedbyclient') + }) + await page.goto(`/s/${sourceId}`) + await expect(page.getByLabel('프롬프트 입력')).toBeVisible() + await expect(page.getByText(prompts[1], { exact: true })).toBeVisible() + return { writes, unexpected, pageErrors, rows, original, source, forkCalls, sendCalls, forkGate, sendGate, listGate, + heldLists: () => heldLists, + assertPreserved: () => expect(JSON.stringify(source)).toBe(original), + assertClean: () => { expect(unexpected).toEqual([]); expect(pageErrors).toEqual([]) }, + release: () => { forkGate.release(); sendGate.release(); listGate.release() } } +} + +async function beginEdit(page: Page, index = 1) { + await page.getByText(prompts[index], { exact: true }).hover() + await page.getByRole('button', { name: '메시지 수정', exact: true }).nth(index).click() + await expect(page.getByLabel('프롬프트 입력')).toHaveValue(prompts[index]) + await expect(page.getByRole('button', { name: '수정 취소', exact: true })).toBeVisible() +} + +async function openSidebarControl(page: Page, name: string | RegExp) { + const button = page.getByRole('button', { name, exact: typeof name === 'string' }) + const bounds = await button.boundingBox() + if (!bounds || bounds.x < 0 || bounds.x + bounds.width > (page.viewportSize()?.width ?? 0)) { + await page.getByRole('button', { name: '사이드바 토글', exact: true }).click() + } + await button.click() +} + +async function settleUi(page: Page) { + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) +} + +for (const index of [1, 2]) { + test(`${index === 1 ? 'middle' : 'last'} edit forks a prefix and survives reload without changing the original`, async ({ page }, info) => { + const state = await fixture(page, info) + await beginEdit(page, index) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.screenshot({ path: info.outputPath(`editing-${info.project.name}.png`), animations: 'disabled' }) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page).toHaveURL(new RegExp(`/s/${forkId}$`)) + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.forkCalls()).toHaveLength(1) + expect(state.forkCalls()[0].path).toBe(`/sessions/${sourceId}/messages/${userIds[index]}/fork`) + expect(state.sendCalls()).toEqual([{ method: 'POST', path: `/sessions/${forkId}/messages`, + data: expect.objectContaining({ content: edited }) }]) + const saved = state.rows.get(forkId)! + expect(saved.messages.map((entry) => entry.content)).toEqual([ + ...prompts.slice(0, index).flatMap((prompt, n) => [prompt, answers[n]]), edited, freshAnswer, + ]) + state.assertPreserved() + await page.reload() + await expect(page.getByText(edited, { exact: true })).toBeVisible() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + await expect(page.getByText(prompts[index], { exact: true })).toHaveCount(0) + if (index === 1) await expect(page.getByText(prompts[2], { exact: true })).toHaveCount(0) + await page.screenshot({ path: info.outputPath(`edited-fork-${info.project.name}.png`), animations: 'disabled' }) + expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)).toBe(false) + await page.goto(`/s/${sourceId}`) + for (const original of [...prompts, ...answers]) await expect(page.getByText(original, { exact: true })).toBeVisible() + state.assertPreserved() + state.assertClean() + }) +} + +for (const action of ['cancel', 'Escape'] as const) { + test(`${action} restores the existing draft without creating a fork`, async ({ page }, info) => { + const state = await fixture(page, info) + const input = page.getByLabel('프롬프트 입력') + await input.fill('전송하지 않은 기존 초안') + await beginEdit(page) + await input.fill('취소할 수정 내용') + if (action === 'cancel') await page.getByRole('button', { name: '수정 취소', exact: true }).click() + else await input.press('Escape') + await expect(input).toHaveValue('전송하지 않은 기존 초안') + await expect(page.getByRole('button', { name: '수정 후 다시 보내기', exact: true })).toHaveCount(0) + expect(state.writes).toEqual([]) + state.assertPreserved() + state.assertClean() + }) +} + +test('blank edits and composing Enter do not fork or send', async ({ page }, info) => { + const state = await fixture(page, info) + await beginEdit(page) + const input = page.getByLabel('프롬프트 입력') + const submit = page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }) + await input.fill(' \n ') + await expect(submit).toBeDisabled() + await input.press('Enter') + await input.fill(edited) + await input.dispatchEvent('compositionstart') + await input.dispatchEvent('keydown', { key: 'Enter', code: 'Enter', isComposing: true, keyCode: 229 }) + expect(state.writes).toEqual([]) + await input.dispatchEvent('compositionend') + await input.press('Enter') + await expect.poll(() => state.sendCalls().length).toBe(1) + state.assertPreserved() + state.assertClean() +}) + +test('cancel restores draft files and selected skills changed during editing', async ({ page }, info) => { + const state = await fixture(page, info, { attachments: true, skill: true }) + const input = page.getByLabel('프롬프트 입력') + await input.fill('파일과 스킬이 있는 기존 초안') + await page.getByLabel('파일 선택', { exact: true }).setInputFiles({ + name: 'additional.txt', mimeType: 'text/plain', buffer: Buffer.from('Synthetic draft attachment.'), + }) + await expect(page.getByRole('button', { name: 'additional.txt 제거', exact: true })).toBeVisible() + const moreTools = page.getByRole('button', { name: '도구 더보기', exact: true }) + if (await moreTools.isVisible()) await moreTools.click() + await page.getByRole('button', { name: '스킬', exact: true }).click() + await page.getByRole('menuitemcheckbox', { name: /초안 검토/ }).click() + await page.keyboard.press('Escape') + await expect(page.getByRole('button', { name: '초안 검토 제거', exact: true })).toBeVisible() + await beginEdit(page) + await page.getByRole('button', { name: '초안 검토 제거', exact: true }).click() + await page.getByRole('button', { name: 'notes.txt 제거', exact: true }).click() + await page.getByRole('button', { name: '수정 취소', exact: true }).click() + await expect(input).toHaveValue('파일과 스킬이 있는 기존 초안') + await expect(page.getByRole('button', { name: 'additional.txt 제거', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '초안 검토 제거', exact: true })).toBeVisible() + expect(state.forkCalls()).toHaveLength(0) + expect(state.sendCalls()).toHaveLength(0) + state.assertPreserved() + state.assertClean() +}) + +test('pending fork blocks duplicate submissions and restores the send control', async ({ page }, info) => { + const state = await fixture(page, info, { holdFork: true }) + try { + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + const submit = page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }) + await submit.click() + await expect.poll(() => state.forkCalls().length).toBe(1) + await expect(submit).toBeDisabled() + await page.getByLabel('프롬프트 입력').press('Enter') + expect(state.forkCalls()).toHaveLength(1) + expect(state.sendCalls()).toHaveLength(0) + state.forkGate.release() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.sendCalls()).toHaveLength(1) + state.assertPreserved() + state.assertClean() + } finally { state.release() } +}) + +for (const streamResponses of [true, false]) { +test(`an in-flight answer disables editing existing messages: streaming=${streamResponses}`, async ({ page }, info) => { + const state = await fixture(page, info, { holdSend: true, streamResponses }) + try { + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect.poll(() => state.sendCalls().length).toBe(1) + const pencils = page.getByRole('button', { name: '메시지 수정', exact: true }) + expect(await pencils.evaluateAll((buttons) => buttons.every((button) => (button as HTMLButtonElement).disabled))).toBe(true) + state.sendGate.release() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + state.assertPreserved() + state.assertClean() + } finally { state.release() } +}) +} + +test('edited attachment uses the cloned file ID, not the source attachment', async ({ page }, info) => { + const state = await fixture(page, info, { attachments: true }) + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.sendCalls()[0].data.attachments).toEqual([cloneFileId]) + expect(state.rows.get(forkId)!.messages.at(-2)?.attachments[0].id).toBe(cloneFileId) + await page.reload() + await expect(page.getByRole('button', { name: /notes\.txt/ })).toBeVisible() + state.assertPreserved() + state.assertClean() +}) + +test('removing an attachment while editing does not reattach its cloned file', async ({ page }, info) => { + const state = await fixture(page, info, { attachments: true }) + await beginEdit(page) + await page.getByRole('button', { name: 'notes.txt 제거', exact: true }).click() + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.sendCalls()[0].data.attachments ?? []).toEqual([]) + expect(state.rows.get(forkId)!.messages.at(-2)?.attachments).toEqual([]) + state.assertPreserved() + state.assertClean() +}) + +test('equal file names are mapped by ID after one attachment is removed', async ({ page }, info) => { + const state = await fixture(page, info, { attachments: true, duplicateAttachments: true }) + await beginEdit(page) + const remove = page.getByRole('button', { name: 'notes.txt 제거', exact: true }) + await expect(remove).toHaveCount(2) + await remove.first().click() + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.sendCalls()[0].data.attachments).toEqual([duplicateCloneId]) + expect(state.rows.get(forkId)!.messages.at(-2)?.attachments.map((file) => file.id)).toEqual([duplicateCloneId]) + state.assertPreserved() + state.assertClean() +}) + +test('an upload during editing stays private until the forked message is sent', async ({ page }, info) => { + const state = await fixture(page, info, { attachments: true }) + await beginEdit(page) + await page.getByLabel('파일 선택', { exact: true }).setInputFiles({ + name: 'additional.txt', mimeType: 'text/plain', buffer: Buffer.from('Synthetic added attachment.'), + }) + await expect(page.getByRole('button', { name: 'additional.txt 제거', exact: true })).toBeVisible() + const uploads = state.writes.filter((write) => write.path === '/files') + expect(uploads).toHaveLength(1) + expect(uploads[0].data.multipart).not.toContain(sourceId) + expect(uploads[0].data.multipart).not.toMatch(/name="session_?[Ii]d"/) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.sendCalls()[0].data.attachments).toEqual([cloneFileId, addedFileId]) + state.assertPreserved() + state.assertClean() +}) + +test('privacy 409 retries the same fork and cloned attachments with its decision token', async ({ page }, info) => { + const state = await fixture(page, info, { attachments: true, privacy: true }) + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '개인정보가 포함된 요청입니다' }) + await expect(dialog).toBeVisible() + await expect(page).toHaveURL(new RegExp(`/s/${sourceId}$`)) + expect(state.rows.get(forkId)!.messages).toHaveLength(2) + await dialog.getByRole('button', { name: '가린 뒤 기존 모델 사용' }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.forkCalls()).toHaveLength(1) + expect(state.sendCalls()).toHaveLength(2) + expect(state.sendCalls().every((call) => call.path === `/sessions/${forkId}/messages`)).toBe(true) + expect(state.sendCalls()[1].data).toMatchObject({ content: edited, attachments: [cloneFileId], + privacyAction: 'mask_external', privacyDecisionToken: 'fixture-bound-to-fork' }) + state.assertPreserved() + state.assertClean() +}) + +test('privacy edit return removes a cloned attachment and requests fresh consent on the same fork', async ({ page }, info) => { + const state = await fixture(page, info, { attachments: true, privacy: true }) + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + const submit = page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }) + await submit.click() + const dialog = page.getByRole('dialog', { name: '개인정보가 포함된 요청입니다' }) + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: '편집으로 돌아가기', exact: true }).click() + await page.getByRole('button', { name: 'notes.txt 제거', exact: true }).click() + await submit.click() + await expect(dialog).toBeVisible() + expect(state.sendCalls()[1].data.attachments ?? []).toEqual([]) + expect(state.sendCalls()[1].data.privacyDecisionToken).toBeUndefined() + await dialog.getByRole('button', { name: '가린 뒤 기존 모델 사용', exact: true }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.forkCalls()).toHaveLength(1) + expect(state.sendCalls()).toHaveLength(3) + expect(state.sendCalls()[2].data).toMatchObject({ privacyAction: 'mask_external', + privacyDecisionToken: 'fixture-revised-envelope' }) + expect(state.sendCalls()[2].data.attachments ?? []).toEqual([]) + state.assertPreserved() + state.assertClean() +}) + +for (const failure of ['fork', 'send'] as const) { + test(`${failure} failure keeps the edited draft and permits retry`, async ({ page }, info) => { + const state = await fixture(page, info, { failFork: failure === 'fork', failSend: failure === 'send' }) + await page.getByLabel('프롬프트 입력').fill('보존할 기존 초안') + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page.getByRole('alert')).toContainText('전송하지 못했습니다') + await expect(page.getByLabel('프롬프트 입력')).toHaveValue(edited) + await expect(page).toHaveURL(new RegExp(`/s/${sourceId}$`)) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + expect(state.forkCalls()).toHaveLength(failure === 'fork' ? 2 : 1) + expect(state.sendCalls()).toHaveLength(failure === 'fork' ? 1 : 2) + state.assertPreserved() + state.assertClean() + }) +} + +for (const recovery of ['stored', 'unknown'] as const) { + test(`a ${recovery} send outcome does not offer automatic edit resubmission`, async ({ page }, info) => { + const state = await fixture(page, info, { failSend: true, failSendStored: recovery === 'stored', + failRecoveryLookup: recovery === 'unknown' }) + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page).toHaveURL(new RegExp(`/s/${forkId}$`)) + await expect(page.getByText(recovery === 'stored' + ? '새 대화에 전송 기록이 있습니다. 저장된 답변을 확인한 뒤 다시 시도하세요.' + : '전송 상태를 확인하지 못했습니다. 새 대화를 새로고침해 확인한 뒤 다시 시도하세요.', { exact: true })).toBeVisible() + const input = page.getByLabel('프롬프트 입력') + await expect(input).toHaveValue('') + await expect(page.getByRole('button', { name: '수정 후 다시 보내기', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '전송', exact: true })).toBeDisabled() + await input.press('Enter') + expect(state.forkCalls()).toHaveLength(1) + expect(state.sendCalls()).toHaveLength(1) + if (recovery === 'stored') { + await expect(page.getByText(edited, { exact: true })).toBeVisible() + expect(state.rows.get(forkId)!.messages.at(-1)?.content).toBe(edited) + } + state.assertPreserved() + state.assertClean() + }) +} + +test('a newly sent message becomes editable after stored IDs reconcile without reload', async ({ page }, info) => { + const state = await fixture(page, info) + const content = '저장 ID를 확인할 새 질문입니다.' + await page.getByLabel('프롬프트 입력').fill(content) + await page.getByRole('button', { name: '전송', exact: true }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + await page.getByText(content, { exact: true }).hover() + const pencil = page.getByRole('button', { name: '메시지 수정', exact: true }).last() + await expect(pencil).toBeEnabled() + await pencil.click() + await expect(page.getByLabel('프롬프트 입력')).toHaveValue(content) + await expect(page.getByRole('button', { name: '수정 후 다시 보내기', exact: true })).toBeVisible() + expect(state.forkCalls()).toHaveLength(0) + expect(state.sendCalls()).toHaveLength(1) + expect(state.rows.get(sourceId)!.messages.at(-2)?.id).toBe('cccccccccccccccccccccccccccccccc') + state.assertClean() +}) + +test('moving to another session does not carry the edit target or edited draft', async ({ page }, info) => { + const state = await fixture(page, info) + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await openSidebarControl(page, '다른 대화') + await expect(page).toHaveURL(new RegExp(`/s/${otherId}$`)) + await expect(page.getByLabel('프롬프트 입력')).toHaveValue('') + await expect(page.getByRole('button', { name: '수정 후 다시 보내기', exact: true })).toHaveCount(0) + expect(state.writes).toEqual([]) + state.assertPreserved() + state.assertClean() +}) + +for (const phase of ['fork', 'send', 'privacy'] as const) { + test(`a delayed ${phase} response does not take over another session`, async ({ page }, info) => { + const state = await fixture(page, info, { holdFork: phase === 'fork', holdSend: phase !== 'fork', + privacy: phase === 'privacy' }) + try { + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect.poll(() => phase === 'fork' ? state.forkCalls().length : state.sendCalls().length).toBe(1) + await openSidebarControl(page, '다른 대화') + await expect(page).toHaveURL(new RegExp(`/s/${otherId}$`)) + const response = page.waitForResponse((res) => res.request().method() === 'POST' + && res.url().endsWith(phase === 'fork' ? '/fork' : '/messages')) + state.release() + await response + await settleUi(page) + await expect(page).toHaveURL(new RegExp(`/s/${otherId}$`)) + await expect(page.getByLabel('프롬프트 입력')).toHaveValue('') + await expect(page.getByRole('button', { name: '수정 후 다시 보내기', exact: true })).toHaveCount(0) + await expect(page.getByRole('dialog', { name: '개인정보가 포함된 요청입니다' })).toHaveCount(0) + await expect(page.getByText(edited, { exact: true })).toHaveCount(0) + await expect(page.getByText(freshAnswer, { exact: true })).toHaveCount(0) + expect(state.sendCalls()).toHaveLength(phase === 'fork' ? 0 : 1) + state.assertPreserved() + state.assertClean() + } finally { state.release() } + }) +} + +test('a fork started by account A cannot populate account B after login', async ({ page }, info) => { + const state = await fixture(page, info, { holdFork: true }) + try { + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect.poll(() => state.forkCalls().length).toBe(1) + await openSidebarControl(page, /계정 메뉴.*fixture@example\.test/) + await page.getByRole('menuitem', { name: '로그아웃', exact: true }).click() + await page.getByLabel('이메일', { exact: true }).fill('other@example.test') + await page.getByLabel('비밀번호', { exact: true }).fill('Synthetic-fixture-only-123!') + await page.locator('form').getByRole('button', { name: '로그인', exact: true }).click() + await openSidebarControl(page, '다른 대화') + await expect(page).toHaveURL(new RegExp(`/s/${otherId}$`)) + const response = page.waitForResponse((res) => res.request().method() === 'POST' && res.url().endsWith('/fork')) + state.forkGate.release() + await response + await settleUi(page) + await expect(page).toHaveURL(new RegExp(`/s/${otherId}$`)) + await expect(page.getByLabel('프롬프트 입력')).toHaveValue('') + await expect(page.getByRole('button', { name: '원본 대화', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '수정된 대화', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '수정 후 다시 보내기', exact: true })).toHaveCount(0) + for (const text of [...prompts, edited, freshAnswer]) await expect(page.getByText(text, { exact: true })).toHaveCount(0) + expect(state.sendCalls()).toHaveLength(0) + state.assertPreserved() + state.assertClean() + } finally { state.release() } +}) + +test('a post-send session list from account A cannot replace account B workspace', async ({ page }, info) => { + const state = await fixture(page, info, { holdPostSendList: true }) + try { + await beginEdit(page) + await page.getByLabel('프롬프트 입력').fill(edited) + await page.getByRole('button', { name: '수정 후 다시 보내기', exact: true }).click() + await expect(page.getByText(freshAnswer, { exact: true })).toBeVisible() + await expect.poll(state.heldLists).toBeGreaterThan(0) + await openSidebarControl(page, /계정 메뉴.*fixture@example\.test/) + await page.getByRole('menuitem', { name: '로그아웃', exact: true }).click() + await page.getByLabel('이메일', { exact: true }).fill('other@example.test') + await page.getByLabel('비밀번호', { exact: true }).fill('Synthetic-fixture-only-123!') + await page.locator('form').getByRole('button', { name: '로그인', exact: true }).click() + await openSidebarControl(page, '다른 대화') + await expect(page).toHaveURL(new RegExp(`/s/${otherId}$`)) + const response = page.waitForResponse((res) => res.request().method() === 'GET' && res.url().endsWith('/sessions')) + state.listGate.release() + await response + await settleUi(page) + await expect(page).toHaveURL(new RegExp(`/s/${otherId}$`)) + await expect(page.getByLabel('프롬프트 입력')).toHaveValue('') + await expect(page.getByRole('button', { name: '원본 대화', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '수정된 대화', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '다른 대화', exact: true })).toHaveCount(1) + for (const text of [...prompts, edited, freshAnswer]) await expect(page.getByText(text, { exact: true })).toHaveCount(0) + expect(state.sendCalls()).toHaveLength(1) + state.assertPreserved() + state.assertClean() + } finally { state.release() } +}) diff --git a/apps/web/playwright.edit-resend.config.ts b/apps/web/playwright.edit-resend.config.ts new file mode 100644 index 00000000..31a91a20 --- /dev/null +++ b/apps/web/playwright.edit-resend.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test' +import base from './playwright.config' + +export default defineConfig({ + ...base, + testMatch: 'edit-resend-message.spec.ts', + retries: 0, + reporter: 'list', + use: { ...base.use, baseURL: 'http://127.0.0.1:5304', trace: 'retain-on-failure' }, + webServer: { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 5304 --strictPort', + env: { API_BASE_URL: 'http://127.0.0.1:59999' }, + url: 'http://127.0.0.1:5304', + reuseExistingServer: false, + timeout: 120_000, + }, + projects: [ + { name: 'desktop', use: { viewport: { width: 1440, height: 900 } } }, + { name: 'mobile', use: { viewport: { width: 390, height: 844 } } }, + ], +}) diff --git a/apps/web/src/components/chat/Composer.tsx b/apps/web/src/components/chat/Composer.tsx index 3f20fbbb..2d6f6c0f 100644 --- a/apps/web/src/components/chat/Composer.tsx +++ b/apps/web/src/components/chat/Composer.tsx @@ -9,6 +9,7 @@ import { Mic, MoreHorizontal, Paperclip, + Pencil, Plug, Loader2, Plus, @@ -33,7 +34,7 @@ import { useMediaQuery } from '@/lib/useMediaQuery' import { useNavigate } from 'react-router-dom' import { Badge, Button, Dropdown, MenuItem, MenuLabel, MenuSeparator, Modal } from '@/components/ui' import { cn } from '@/lib/utils' -import { effectiveModelId, useStore } from '@/store/useStore' +import { ChatSendRecoveryError, effectiveModelId, useStore } from '@/store/useStore' import type { ModelInfo, PrivacyAction, SessionKind, Skill, StartingPoint } from '@/types' import { ASPECTS, servedAspect, servedAspects } from '@/lib/aspects' import { ModelPicker } from './ModelPicker' @@ -302,20 +303,32 @@ function AvOptions() { /** The toggle's three positions; `auto` is sent as `'auto'`, the others as booleans. */ type WebSearchMode = 'auto' | 'on' | 'off' -let carriedComposer: { +type ComposerSnapshot = { sessionId: string value: string attachments: FileRow[] startingTemplate: StartingPoint | null activatedSkillIds: string[] webSearchMode: WebSearchMode -} | null = null + startingValues?: Record +} +let carriedComposer: ComposerSnapshot | null = null +// Editing a past question must not replace the ordinary draft of its conversation. +const editBackups = new Map() // Unsent text keyed by session id, or `new:` on the home screen. // Survives remounts, not reloads. const drafts = new Map() const draftKeyFor = (sessionId: string | null, kind: SessionKind) => sessionId ?? `new:${kind}` +// These module caches survive remounts, but must never survive an account change. +useStore.subscribe((state, previous) => { + if (state.accountEpoch === previous.accountEpoch && state.user?.id === previous.user?.id) return + carriedComposer = null + editBackups.clear() + drafts.clear() +}) + /** Whether a session has unsent text. */ export function hasUnsentDraft(sessionId: string) { return !!drafts.get(sessionId)?.trim() @@ -335,6 +348,19 @@ export function Composer({ const t = useT() const isMedia = kind === 'image' || kind === 'av' const canWebSearch = kind === 'chat' || kind === 'report' || kind === 'slides' + const messageEdit = useStore((s) => s.messageEdit) + const accountEpoch = useStore((s) => s.accountEpoch) + const clearMessageEdit = useStore((s) => s.clearMessageEdit) + const setMessageEditBusy = useStore((s) => s.setMessageEditBusy) + const forkBeforeMessage = useStore((s) => s.forkBeforeMessage) + const editing = kind === 'chat' && messageEdit?.sessionId === sessionId ? messageEdit : null + const editContext = useRef<{ + sessionId: string; messageId: string; sourceAttachmentIds: string[] + forkId?: string; files?: FileRow[]; attachmentIdMap?: Record + } | null>(null) + const editSending = useRef(false) + const composing = useRef(false) + const [editPending, setEditPending] = useState(false) const draftKey = draftKeyFor(sessionId, kind) const [value, setValue] = useState(() => drafts.get(draftKey) ?? '') const liveValue = useRef(value) @@ -349,8 +375,8 @@ export function Composer({ setValue(own) return } - drafts.set(draftKey, value) - }, [draftKey, value]) + if (!editing && editContext.current?.sessionId !== draftKey) drafts.set(draftKey, value) + }, [draftKey, value, editing]) const restoreSequence = useRef(0) const activeRestoreToken = useRef(null) @@ -600,14 +626,47 @@ export function Composer({ startingTemplate: liveStartingTemplate.current, activatedSkillIds: liveActivatedSkillIds.current, webSearchMode: liveWebSearchMode.current, + startingValues, }) + const restoreComposer = (held: ComposerSnapshot) => { + liveValue.current = held.value + liveAttachments.current = held.attachments + liveActivatedSkillIds.current = held.activatedSkillIds + liveStartingTemplate.current = held.startingTemplate + setValue(held.value) + setAttachments(held.attachments) + setActivatedSkillIds(held.activatedSkillIds) + setStartingTemplate(held.startingTemplate) + setStartingValues(held.startingValues ?? {}) + setWebSearchMode(held.webSearchMode) + drafts.set(held.sessionId, held.value) + } // Per-turn state resets when the surface or session changes; the typed sentence stays. const initializedScope = useRef<{ sessionId: string | null; kind: SessionKind } | null>(null) + const initializedAccount = useRef(accountEpoch) useEffect(() => { + if (initializedAccount.current !== accountEpoch) { + initializedAccount.current = accountEpoch + editContext.current = null + editSending.current = false + setEditPending(false) + setPendingPrivacy(null) + setReusableSessionId(null) + activeRestoreToken.current = null + liveValue.current = '' + setValue('') + initializedScope.current = null + } // StrictMode replays setup; a consumed handoff must not become a reset. const previous = initializedScope.current if (previous?.sessionId === sessionId && previous.kind === kind) return initializedScope.current = { sessionId, kind } + const backup = sessionId ? editBackups.get(sessionId) : undefined + if (backup && messageEdit?.sessionId !== sessionId) { + editBackups.delete(sessionId!) + restoreComposer(backup) + return + } if (sessionId && carriedComposer?.sessionId === sessionId) { // Only non-empty fields are put back: on a send the composer was cleared // before the session existed, and a refusal may already have restored @@ -640,7 +699,7 @@ export function Composer({ liveAttachments.current = [] setAttachments([]) setWebSearchMode('auto') - }, [sessionId, kind]) + }, [sessionId, kind, messageEdit?.sessionId, accountEpoch]) const ref = useRef(null) const navigate = useNavigate() const { @@ -803,6 +862,59 @@ export function Composer({ const streaming = !!sessionId && !!running[sessionId] const busy = isMedia ? jobRunning : streaming + useEffect(() => { + if (!editing) { + if (messageEdit && messageEdit.sessionId !== sessionId) clearMessageEdit() + if (editContext.current?.sessionId !== sessionId) editContext.current = null + return + } + if (editContext.current?.sessionId === sessionId && editContext.current.messageId === editing.messageId) return + if (busy || uploading || pendingPrivacy || privacyRetrying || recording || transcribing || modelSelectionPending || editSending.current) { + clearMessageEdit() + return + } + const target = session?.messages.find((message) => message.id === editing.messageId && message.role === 'user') + if (!target || !sessionId) { + clearMessageEdit() + return + } + if (!editBackups.has(sessionId)) editBackups.set(sessionId, heldComposer(sessionId)) + editContext.current = { sessionId, messageId: target.id, + sourceAttachmentIds: (target.attachments ?? []).flatMap((file) => file.id ? [file.id] : []) } + activeRestoreToken.current = null + setReusableSessionId(null) + setChatError(null) + liveValue.current = target.content + setValue(target.content) + const files: FileRow[] = (target.attachments ?? []).filter((file) => !!file.id).map((file) => ({ + id: file.id!, name: file.name, size: typeof file.size === 'number' ? file.size : 0, + mime: file.type || 'application/octet-stream', tokens: 0, projectId: projectId ?? null, + sessionId, preview: '', error: null, createdAt: target.createdAt, + })) + liveAttachments.current = files + setAttachments(files) + liveStartingTemplate.current = null + setStartingTemplate(null) + setStartingValues({}) + requestAnimationFrame(() => { ref.current?.focus(); ref.current?.scrollIntoView({ block: 'nearest' }) }) + // A message edit is an explicit one-time injection, not an ordinary draft update. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messageEdit, sessionId]) + + const cancelEditing = () => { + if (editSending.current || !editContext.current) return + const original = editBackups.get(editContext.current.sessionId) + editBackups.delete(editContext.current.sessionId) + editContext.current = null + clearMessageEdit() + setPendingPrivacy(null) + setReusableSessionId(null) + setChatError(null) + activeRestoreToken.current = null + if (original) restoreComposer(original) + ref.current?.focus() + } + const deliverChat = async ( targetSessionId: string | null, text: string, @@ -816,6 +928,23 @@ export function Composer({ ) => { setChatError(null) const resolvedSessionId = targetSessionId ?? reusableSessionId + const editScope = editContext.current?.forkId === resolvedSessionId ? editContext.current : null + const origin = useStore.getState().activeSessionId + const originPath = window.location.pathname + const owner = useStore.getState().user?.id + const epoch = useStore.getState().accountEpoch + let acceptedHere = false + const ownsView = () => { + const state = useStore.getState() + if (state.user?.id !== owner || state.accountEpoch !== epoch) return false + if (!editScope) return true + // Router effects may lag behind an already committed browser navigation. + if (window.location.pathname !== originPath && + !(acceptedHere && window.location.pathname === `/s/${resolvedSessionId}`)) return false + return (state.activeSessionId === origin && editContext.current === editScope) || + (acceptedHere && !editContext.current && + (state.activeSessionId === resolvedSessionId || state.activeSessionId === origin)) + } let attemptedSessionId = resolvedSessionId try { const acceptedSessionId = await send(resolvedSessionId, 'chat', text, { @@ -827,17 +956,32 @@ export function Composer({ startingTemplate: startedFrom ?? undefined, privacyAction: action, privacyDecisionToken: decisionToken, + reconcileBeforeRetry: Boolean(editScope), onSession: (id) => { + if (!ownsView()) return + acceptedHere = true attemptedSessionId = id carriedComposer = heldComposer(id) + if (editContext.current?.forkId === id) { + editContext.current = null + clearMessageEdit() + } navigate(`/s/${id}`, { replace: true }) }, }) + if (!ownsView()) return if (!sessionId) navigate(`/s/${acceptedSessionId}`, { replace: true }) activeRestoreToken.current = null setReusableSessionId(null) setPendingPrivacy(null) } catch (error) { + if (!ownsView()) return + if (error instanceof ChatSendRecoveryError) { + setNotice(error.recovery === 'stored' + ? t('새 대화에 전송 기록이 있습니다. 저장된 답변을 확인한 뒤 다시 시도하세요.') + : t('전송 상태를 확인하지 못했습니다. 새 대화를 새로고침해 확인한 뒤 다시 시도하세요.')) + return + } if (error instanceof PrivacyDecisionError) { const decisionSessionId = error.sessionId ?? resolvedSessionId setReusableSessionId(decisionSessionId) @@ -854,6 +998,7 @@ export function Composer({ return } setReusableSessionId((current) => current ?? attemptedSessionId) + if (editScope) throw error // Branch on the code: `errorMessage` swallows machine strings. const notice = errorCode(error) === 'auto_quality_model_required' @@ -902,15 +1047,17 @@ export function Composer({ /** Shared picker, drop, and paste upload path. */ const addFiles = async (picked: File[]) => { - if (!picked.length || isMedia) return + if (!picked.length || isMedia || editPending) return + const editScope = editContext.current + const uploadSession = sessionId setUploading(true) try { for (const file of picked) { const row = await uploadFile(file, { - projectId: projectId ?? undefined, - sessionId: sessionId ?? undefined, + projectId: editing ? undefined : projectId ?? undefined, + sessionId: editing ? undefined : sessionId ?? undefined, }).catch(() => null) - if (row) { + if (row && editContext.current === editScope && (!editScope || useStore.getState().activeSessionId === uploadSession)) { setAttachments((current) => { activeRestoreToken.current = null const next = [...current, row] @@ -927,7 +1074,7 @@ export function Composer({ // Media surfaces take no attachments. const { over: dragging, handlers: dropHandlers } = useFileDrop( (files) => void addFiles(files), - !isMedia, + !isMedia && !editPending, ) const onPasteFiles = usePasteFiles((files) => void addFiles(files)) @@ -961,7 +1108,73 @@ export function Composer({ const submit = (spoken?: string) => { const text = withStartingValues((spoken ?? value).trim()) - if (!text || busy || modelSelectionPending || unsupportedVideo) return + if (!text || busy || composing.current || modelSelectionPending || unsupportedVideo || uploading || editSending.current || pendingPrivacy) return + if (editing && editContext.current) { + if (sessionId && useStore.getState().running[sessionId]) return + const context = editContext.current + const origin = sessionId + const originPath = window.location.pathname + const owner = useStore.getState().user?.id + const epoch = useStore.getState().accountEpoch + const ownsAccount = () => useStore.getState().user?.id === owner && useStore.getState().accountEpoch === epoch + const ownsEdit = () => ownsAccount() && editContext.current === context && + useStore.getState().activeSessionId === origin && window.location.pathname === originPath + const search = sentWebSearch + const skillIds = activeSkills.map((skill) => skill.id) + const selectedFiles = attachments + editSending.current = true + setEditPending(true) + setMessageEditBusy(true) + setChatError(null) + void (async () => { + try { + if (!context.forkId) { + const fork = await forkBeforeMessage(context.sessionId, context.messageId) + context.forkId = fork.sessionId + context.files = fork.attachments + context.attachmentIdMap = fork.attachmentIdMap + } + // Navigating away while the fork request was pending must not send from another view. + if (!ownsEdit()) return + const files = selectedFiles.map((file) => { + if (!context.sourceAttachmentIds.includes(file.id)) return file + const clonedId = context.attachmentIdMap?.[file.id] + const cloned = context.files?.find((candidate) => candidate.id === clonedId) + if (!cloned) throw new Error(t('첨부 파일을 복제하지 못했습니다. 다시 시도하세요.')) + return cloned + }) + context.files = [...(context.files ?? []), ...files.filter((file) => !context.files?.some((existing) => existing.id === file.id))] + const restoreToken = ++restoreSequence.current + activeRestoreToken.current = restoreToken + liveValue.current = '' + liveAttachments.current = [] + liveActivatedSkillIds.current = [] + setValue('') + setAttachments([]) + setActivatedSkillIds([]) + await deliverChat(context.forkId, text, files, search, skillIds, null, undefined, undefined, restoreToken) + } catch (error) { + if (!ownsEdit()) return + liveValue.current = text + liveAttachments.current = selectedFiles.map((file) => { + const clonedId = context.attachmentIdMap?.[file.id] + return context.files?.find((candidate) => candidate.id === clonedId) ?? file + }) + liveActivatedSkillIds.current = skillIds + setValue(text) + setAttachments(liveAttachments.current) + setActivatedSkillIds(skillIds) + setChatError(errorMessage(error, t('수정한 메시지를 전송하지 못했습니다. 다시 시도하세요.'))) + } finally { + if (ownsAccount()) { + editSending.current = false + setEditPending(false) + setMessageEditBusy(false) + } + } + })() + return + } clearMediaError() const attachmentIds = attachments.map((f) => f.id) const attachmentLabels = attachments.map((f) => f.name) @@ -1102,6 +1315,16 @@ export function Composer({ )}
+ {editing && ( +
+ + {t('메시지 수정 · 새 대화')} + +
+ )} {(project || attachments.length > 0 || webSearch || @@ -1183,6 +1406,7 @@ export function Composer({ void setSessionTemplate(session.id, null) } }} + disabled={!!editing} aria-label={t('{name} 서식 해제').replace( '{name}', templateText(shownTemplate, currentLang() === 'en').name, @@ -1375,14 +1599,22 @@ export function Composer({ autoFocus={autoFocus} rows={1} value={value} + readOnly={editPending} + onCompositionStart={() => { composing.current = true }} + onCompositionEnd={() => { composing.current = false }} onChange={(e) => { activeRestoreToken.current = null liveValue.current = e.target.value // Written synchronously: picking a starting point navigates before an effect would run. - drafts.set(draftKey, e.target.value) + if (!editing) drafts.set(draftKey, e.target.value) setValue(e.target.value) }} onKeyDown={(e) => { + if (e.key === 'Escape' && editing && !e.nativeEvent.isComposing) { + e.preventDefault() + cancelEditing() + return + } if (holdToTalk(e)) return if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() @@ -1462,7 +1694,7 @@ export function Composer({ )} {/* Only once the conversation has started; the empty screen offers the same button. */} - {hasTemplates && started && !folded && ( + {hasTemplates && started && !folded && !editing && ( + )} {copyButton('프롬프트 복사')}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9335500d..edbb8637 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -804,6 +804,10 @@ export interface SessionRow { } export const sessionsApi = { + forkBeforeMessage: (sessionId: string, messageId: string) => + call<{ session: SessionRow; attachments: FileRow[]; attachmentIdMap: Record }>( + `/sessions/${sessionId}/messages/${messageId}/fork`, { method: 'POST' }, + ), /** Asks the running turn to stop. Sent before the fetch is aborted: a closed socket alone looks like a changed tab. */ stop: (sessionId: string) => call(`/sessions/${sessionId}/stop`, { method: 'POST' }), /** Which of a comparison's answers the conversation continues from. */ diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts index 294b29c0..5d48a0d3 100644 --- a/apps/web/src/lib/i18n.ts +++ b/apps/web/src/lib/i18n.ts @@ -342,6 +342,12 @@ const EN: Record = { '다시 쓰기': 'Rewrite', '다시 연결': 'Reconnect', '다운로드': 'Download', + '메시지 수정': 'Edit message', + '메시지 수정 · 새 대화': 'Edit message · New conversation', + '수정 취소': 'Cancel edit', + '수정 후 다시 보내기': 'Save and resend', + '수정한 메시지를 전송하지 못했습니다. 다시 시도하세요.': 'Could not send the edited message. Please try again.', + '첨부 파일을 복제하지 못했습니다. 다시 시도하세요.': 'Could not copy the attachment. Please try again.', '다음 리필': 'Next refill', '답변': 'Answer', '대규모': 'Large', @@ -2491,6 +2497,8 @@ const EN: Record = { '보관 기간(일)': 'Retention (days)', '이 기억을 엽니다': 'Open this memory', '설정을 불러오거나 저장하는 중입니다': 'Loading or saving settings', + '새 대화에 전송 기록이 있습니다. 저장된 답변을 확인한 뒤 다시 시도하세요.': 'The new conversation contains a sent message. Check the saved reply before retrying.', + '전송 상태를 확인하지 못했습니다. 새 대화를 새로고침해 확인한 뒤 다시 시도하세요.': 'The send status could not be confirmed. Reload the new conversation and check it before retrying.', } diff --git a/apps/web/src/store/useStore.ts b/apps/web/src/store/useStore.ts index 3fe8da6c..b257cfff 100644 --- a/apps/web/src/store/useStore.ts +++ b/apps/web/src/store/useStore.ts @@ -101,6 +101,17 @@ type SidebarMode = 'full' | 'rail' | 'hidden' const isClientRefusal = (error: unknown): error is ApiError => error instanceof ApiError && error.status >= 400 && error.status < 500 +/** A failed HTTP response is not proof that the server stored nothing. */ +export class ChatSendRecoveryError extends Error { + readonly recovery: 'stored' | 'unknown' + + constructor(recovery: 'stored' | 'unknown') { + super(`chat_send_${recovery}`) + this.name = 'ChatSendRecoveryError' + this.recovery = recovery + } +} + /** Per-session PATCH queue for model/routing changes; a send waits for the latest one. */ const sessionPersistence = new Map>() @@ -229,11 +240,15 @@ type SendOptions = { model?: string /** Called as soon as a session id exists, before the stream finishes. */ onSession?: (id: string) => void + /** Edited forks must reconcile an unaccepted request before allowing a retry. */ + reconcileBeforeRetry?: boolean } interface State { // ── auth ────────────────────────────────────────────────────────────── user: User | null + /** Invalidates asynchronous work and in-memory drafts across account changes. */ + accountEpoch: number authenticated: boolean /** True until the boot-time session check finishes. */ authLoading: boolean @@ -320,6 +335,14 @@ interface State { /** Titles only; transcripts arrive with `openSession`. */ loadSessions: () => Promise openSession: (id: string) => Promise + forkBeforeMessage: (sessionId: string, messageId: string) => Promise<{ + sessionId: string; attachments: FileRow[]; attachmentIdMap: Record + }> + messageEdit: { sessionId: string; messageId: string } | null + messageEditBusy: boolean + setMessageEditBusy: (busy: boolean) => void + editMessage: (sessionId: string, messageId: string) => void + clearMessageEdit: () => void newSession: ( kind: SessionKind, opts?: { @@ -806,6 +829,7 @@ function reconcileCompareModels(current: string[], available: ModelInfo[]): stri export const useStore = create((set, get) => ({ user: null, + accountEpoch: 0, pendingDelete: null, mediaError: null, authenticated: false, @@ -852,7 +876,8 @@ export const useStore = create((set, get) => ({ }, login: async (email, password) => { - set({ authError: null }) + set((state) => ({ authError: null, accountEpoch: state.accountEpoch + 1, + messageEdit: null, messageEditBusy: false })) try { const session = await auth.login(email, password) setAccessToken(session.accessToken) @@ -874,7 +899,8 @@ export const useStore = create((set, get) => ({ /** Session handed over by a mailed signup-verification link. */ adoptSession: (session) => { setAccessToken(session.accessToken) - set({ authenticated: true, user: session.user, authLoading: false, authError: null }) + set((state) => ({ authenticated: true, user: session.user, authLoading: false, authError: null, + accountEpoch: state.accountEpoch + 1, messageEdit: null, messageEditBusy: false })) scheduleRefresh(session.expiresIn, () => void get().bootstrap()) void get().loadModels() }, @@ -901,6 +927,9 @@ export const useStore = create((set, get) => ({ }, logout: async (reason) => { + set((state) => ({ accountEpoch: state.accountEpoch + 1, + messageEdit: null, messageEditBusy: false, composerRestore: null, + pendingAttachment: null, draft: '' })) try { await auth.logout() } catch { @@ -1273,8 +1302,12 @@ export const useStore = create((set, get) => ({ }, loadSessions: async () => { + const owner = get().user?.id + const epoch = get().accountEpoch + const ownsAccount = () => owner === get().user?.id && epoch === get().accountEpoch try { const rows = await sessionsApi.list() + if (!ownsAccount()) return set((s) => ({ sessionsLoading: false, sessionsFailed: false, @@ -1284,13 +1317,19 @@ export const useStore = create((set, get) => ({ ), })) } catch { + if (!ownsAccount()) return set({ sessionsLoading: false, sessionsFailed: true }) } }, openSession: async (id) => { + const owner = get().user?.id + const epoch = get().accountEpoch + const ownsAccount = () => owner === get().user?.id && epoch === get().accountEpoch + const accountSet: Set = (patch) => { if (ownsAccount()) set(patch) } try { const row = await sessionsApi.get(id) + if (!ownsAccount()) return const session = toSession(row) set((s) => ({ sessions: s.sessions.some((c) => c.id === id) @@ -1299,10 +1338,11 @@ export const useStore = create((set, get) => ({ })) // A turn still running server-side is polled until its answer lands. - void watchForTheAnswer(set, get, id) + void watchForTheAnswer(accountSet, get, id) // Job cards are server rows, so a reload has to fetch them. const jobRows = await jobsApi.list(id).catch(() => null) + if (!ownsAccount()) return if (jobRows) { set((s) => ({ jobs: [ @@ -1322,6 +1362,7 @@ export const useStore = create((set, get) => ({ const missing = [...wanted].filter((a) => !get().artifacts.some((x) => x.id === a)) if (missing.length === 0) return const rows = await Promise.all(missing.map((a) => artifactsApi.get(a).catch(() => null))) + if (!ownsAccount()) return const found = rows.filter((r) => r !== null).map(toArtifact) if (found.length) set((s) => ({ artifacts: [...found, ...s.artifacts] })) } catch { @@ -1356,15 +1397,46 @@ export const useStore = create((set, get) => ({ return session.id }, + messageEdit: null, + messageEditBusy: false, + setMessageEditBusy: (messageEditBusy) => set({ messageEditBusy }), + clearMessageEdit: () => set({ messageEdit: null }), + editMessage: (sessionId, messageId) => { + const state = get() + const session = state.sessions.find((row) => row.id === sessionId) + if (session?.kind !== 'chat' || state.running[sessionId] || state.messageEditBusy) return + if (!session.messages.some((message) => message.id === messageId && message.role === 'user' && message.persisted)) return + set({ messageEdit: { sessionId, messageId } }) + }, + forkBeforeMessage: async (sessionId, messageId) => { + const owner = get().user?.id + const epoch = get().accountEpoch + const result = await sessionsApi.forkBeforeMessage(sessionId, messageId) + if (!owner || owner !== get().user?.id || epoch !== get().accountEpoch || !get().authenticated) { + throw new Error('account_changed') + } + const fork = toSession(result.session) + set((state) => ({ sessions: [fork, ...state.sessions.filter((row) => row.id !== fork.id)] })) + return { sessionId: fork.id, attachments: result.attachments, attachmentIdMap: result.attachmentIdMap } + }, + /** Entry point for all five surfaces; image and av hand off to their job/media paths. */ send: async (sessionId, kind, text, opts = {}) => { + const owner = get().user?.id + const epoch = get().accountEpoch + const ownsAccount = () => owner === get().user?.id && epoch === get().accountEpoch // A retry resends the original turn's options, not just its sentence. const again = opts.retryOf ? sentWith.get(opts.retryOf) : undefined if (again) opts = { ...again, ...opts } const id = sessionId ?? (await get().newSession(kind, { projectId: opts.projectId ?? null })) await waitForSessionPersistence(id) + if (!ownsAccount()) throw new Error('account_changed') // Chat keeps the originating composer until the first SSE event; other surfaces navigate at once. - const acceptSession = () => opts.onSession?.(id) + let accepted = false + const acceptSession = () => { + accepted = true + if (ownsAccount()) opts.onSession?.(id) + } if (kind !== 'chat') acceptSession() // Snapshot before the optimistic turn: a 4xx means nothing was stored, so it is rolled back. @@ -1439,6 +1511,9 @@ export const useStore = create((set, get) => ({ ), })) + const turnSet: Set = opts.reconcileBeforeRetry + ? (patch) => { if (ownsAccount()) set(patch) } + : set const perform = async () => { if (kind === 'report') { await streamReport( @@ -1499,7 +1574,7 @@ export const useStore = create((set, get) => ({ if (get().running[id]) return id if (get().compareMode && get().compareModels.length >= 2) { - await runComparison(set, get, id, text, { + await runComparison(turnSet, get, id, text, { activatedSkillIds: opts.activatedSkillIds, startingTemplateId: opts.startingTemplate?.id, attachments: opts.attachments, @@ -1511,7 +1586,7 @@ export const useStore = create((set, get) => ({ return id } - await streamTurn(set, get, id, text, model, { + await streamTurn(turnSet, get, id, text, model, { model: opts.model, retryOf, webSearch: opts.webSearch, @@ -1529,6 +1604,7 @@ export const useStore = create((set, get) => ({ try { return await perform() } catch (err) { + if (!ownsAccount()) throw err if (err instanceof PrivacyDecisionError) err.sessionId = id if (isClientRefusal(err) && before) { set((state) => ({ @@ -1543,6 +1619,19 @@ export const useStore = create((set, get) => ({ ), openArtifactId: beforeOpenArtifactId, })) + } else if (kind === 'chat' && opts.reconcileBeforeRetry && !accepted && before) { + const stored = await sessionsApi.get(id).catch(() => null) + if (!ownsAccount()) throw err + const fresh = stored ? toSession(stored) : null + // Only an exact stored prefix proves that this failed request added no turn. + const unchanged = fresh && !fresh.pending && + fresh.messages.length === before.messages.length && + fresh.messages.every((message, index) => message.id === before.messages[index].id && + message.role === before.messages[index].role && message.content === before.messages[index].content) + if (fresh) set((state) => ({ sessions: state.sessions.map((row) => row.id === id ? fresh : row) })) + if (unchanged) throw err + acceptSession() + throw new ChatSendRecoveryError(fresh ? 'stored' : 'unknown') } else if (kind === 'chat') { // Other failures may already have server-side output; keep the session reachable. acceptSession() @@ -2603,6 +2692,7 @@ function upsertStep(steps: Step[] | undefined, step: Step): Step[] { function toMessage(raw: MessageRow): Message { return { id: raw.id, + persisted: true, role: raw.role, content: raw.content, createdAt: raw.createdAt, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 2abaa5c4..65ec8502 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -302,6 +302,8 @@ export interface StartingPoint { export interface Message { id: string + /** The id came from a stored transcript, not a local optimistic turn. */ + persisted?: boolean role: Role content: string /** Present instead of `content` when the turn was run as a model comparison. */