# Chat 并发发送防护计划

## Current Design

- 前端 `ChatPanel` 用 `isTyping` 控制按钮：推理中按钮变成 Stop，但 textarea 仍可输入。
- 键盘 Enter 路径直接调用 `handleSend()`；`handleSend()` 内部没有再次检查 `isTyping` / `isStreamingRef`。
- `/api/chat/stream` 在鉴权和附件处理后，会先把 user message 写入 `messages_v2`，然后 SSE generator 才创建/复用 ACP session 并等待 `session._prompt_lock`。
- `session._prompt_lock` 当前语义是串行排队：第二个请求会等待前一个 prompt 结束，而不是中断前一个 prompt。

## Risks

- 同一个浏览器 tab 可以启动第二个 SSE；前端的 `eventSourceRef`、streaming message refs 会被第二个 turn 覆盖，两个 SSE closure 同时更新消息状态。
- 第二个请求即使一直等锁，也已经把 user message 写入 DB，历史状态会领先于实际 ACP turn。
- 用户看到的行为像“继续发送成功”，但后端只是排队等待；如果第一个流断开或长时间工具运行，就会表现为卡住。

## Target Contract

- 同一 workspace 同一时间最多一个可见 AI turn。
- 前端正在推理时，Enter 和发送按钮都不能提交新 AI message；Stop 是唯一可见控制。
- 后端 `/api/chat/stream` 不支持把新 prompt 排队到正在运行的 prompt 后面。
- 忙碌时后端返回 `agent_busy` SSE error，并且不写入 user message、assistant message 或附件消息历史。
- user message 只在当前请求拿到 canonical prompt lock 后写入 `messages_v2`。

## Implementation Steps

1. 在 `ChatPanel` 的 `handleSend()` 和 Enter 键盘路径上加 `isTyping || isStreamingRef.current` 硬防线。
2. 调整 `/api/chat/stream`：鉴权和 workspace 权限检查保留在入口；user message append 移到成功 acquire `session._prompt_lock` 之后。
3. 调整 prompt lock 策略：如果 lock 已被占用，立即返回 `agent_busy` SSE error + `complete`，不等待、不入队。
4. 增加后端契约测试：模拟已锁住 session，确认返回 busy，且 `_db_append_messages_v2` 没有被调用。

## Verification Checklist

- `backend/tests/test_chat_prompt_lock.py` 覆盖忙碌锁不等待、不抢锁、不写历史。
- 前端 lint/type 检查通过。
- 手工验证路径：AI streaming 中按 Enter 不新增 user message；点击 Stop 仍调用 `/api/chat/cancel`。
