fix: close PI core chat review gaps

This commit is contained in:
2026-08-24 01:28:06 +08:00
parent 612135f911
commit bec93d0918
15 changed files with 1094 additions and 122 deletions

View File

@@ -130,24 +130,60 @@
buffers during target recovery, trims Snapshot-covered items and replays only
a strict continuous tail.
- Fixed a supported concurrency defect exposed by the full suite: the Pi
Session Registry now reuses one Conversation store per project, serializing
simultaneous first bindings instead of racing writes to the same metadata
file.
Session Registry and Coding Project Service now receive the same project-
scoped Conversation store provider, so runtime bindings and metadata writes
serialize through one mutation queue instead of racing the same file.
- Closed the first planner review findings after candidate `612135f`: the
authenticated Host server now admits only the exact image-binary upload
exception; empty/invalid/oversize/not-found attachments have stable typed
errors and supported image signatures are checked before disk writes.
- Made active target ownership atomic across Agent switches. An old or archived
Conversation cannot supply the visible draft/timeline/prompt target, and a
slow automatic or explicit create completion only selects when its captured
project and Agent are still active. Submission flights and errors are scoped
per Conversation, so one pending/rejected target does not disable another.
- Bounded each message to 16 local images and four concurrent uploads. A real
Electron image fixture now traverses the Renderer facade, IPC channel and
authenticated Host server, reads the stored bytes back for the optimistic
timeline preview, observes one upload, and verifies the prompt contains only
attachment ids rather than base64.
- Extended target-only gap recovery: when the first recovery Snapshot remains
behind a buffered continuous tail, Renderer requests one fresh target
Snapshot; a repeated identical stale response becomes a retryable error
instead of remaining in an infinite recovering state.
- Synchronized `README.md` with the current Pi core Chat, first-Conversation,
batch and attachment behavior. PI-130 feature controls and PI-140 legacy
removal remain outside this task.
## Verification
- `pnpm exec vitest run tests/unit/coding-workspace-store.test.ts tests/unit/coding-projects-facade.test.ts tests/unit/coding-attachments-facade.test.ts tests/unit/coding-attachments-routes.test.ts tests/unit/coding-chat-panel.test.tsx tests/unit/coding-conversation-timeline.test.tsx tests/unit/coding-conversations-store.test.tsx tests/unit/coding-core-routes.test.ts tests/unit/host-api.test.ts tests/unit/host-api-proxy.test.ts`: passed, 10 files / 68 tests before the final malformed-batch and Session Registry regression additions.
- `pnpm exec vitest run tests/unit/pi-session-registry.test.ts tests/unit/pi-conversation-runtime.test.ts tests/unit/coding-conversations-store.test.tsx tests/unit/coding-chat-panel.test.tsx`: passed, 4 files / 25 tests.
- `pnpm test`: passed, 217 files / 2303 passed / 2 skipped. The final run is green after fixing the same-project parallel Session Registry write race and stabilizing the AI Hardware async query at its actual loading boundary.
- First implementation candidate `612135f` received planner review result
**Standards Needs Fix / Spec Needs Fix**. PI-120 remained the unique Ready
Frontier; the findings above were treated as blocking rather than advancing
PI-130.
- Post-review focused suites passed: attachment Host routes, stale recovery,
shared ProjectService/Registry mutation queue, slow Agent switch, cross-
Conversation submission isolation, attachment count/concurrency and pressure
tests all pass. The consolidated focused run covered 3 files / 30 tests, and
`coding-chat-panel.test.tsx` covered 6/6 behavior tests.
- `REN-008` actual-path fixture passed with 100 runtime patch items, 20 public
patch batches, 21 SSE frames including Snapshot, 20 Renderer transactions,
20 React commits, 131,148 wire bytes, and measured Main-to-React p95
`35.995 ms` against the `<=50 ms` budget. It includes mixed message blocks,
4 KiB tool output and more than 100 KiB of cumulative thinking output.
- `pnpm test`: passed, 218 files / 2316 passed / 2 skipped.
- `pnpm run typecheck`: passed.
- `pnpm run lint:check`: passed with 0 errors and six unchanged warnings in `ExecutionGraphCard`, `Home`, and `Makelore`.
- `pnpm run build:vite`: passed; existing Vite dynamic-import and chunk-size warnings remain.
- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts`: passed, 1/1.
- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts --repeat-each=20 --workers=1`: passed, 20/20 fresh-userData runs. Every sample asserted Composer editability below 500 ms while runtime Snapshot was held, so the measured sample p95 is below the locked 500 ms budget; the same fixture verifies optimistic prompt visibility and HTTP 202 acceptance without a real Provider.
- `git diff --check`: passed; only repository line-ending conversion notices were printed.
- `pnpm run lint:check`: passed with 0 errors and six unchanged warnings in
`ExecutionGraphCard`, `Home`, and `Makelore`.
- `pnpm run build:vite`: passed; existing Vite dynamic-import and chunk-size
warnings remain.
- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts`: passed,
1/1. The post-review real-image fixture also passed 5/5 fresh-userData repeats
with one upload, Host readback preview, optimistic message, HTTP 202 and no
base64 in later payloads. The earlier text-only first-Conversation fixture
passed 20/20 fresh-userData runs below the 500 ms editability budget.
- `git diff --check`: passed; only repository line-ending conversion notices
were printed.
- Real Provider remains **Explicitly Waived / Accepted Risk** with
`realTurnVerified=false`; no Provider Account, credential isolation,
protocol-specific endpoint, rate-limit or real concurrency behavior is

View File

@@ -128,7 +128,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode
- 核心聊天页左侧按伙伴展示本地 Conversation并提供新建入口首次选择没有 Conversation 的伙伴时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。
- Conversation 历史按需从 Main-owned Snapshot 读取。公开 SSE 只交付 Snapshot 与按 Conversation、worker generation 分组的 `patch-batch`Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation隐藏 Conversation 的流式更新不会提交选中时间线。
- 核心时间线渲染消息、Markdown、thinking、工具、压缩、轮次边界和通知默认保留最近 120 个节点的渲染窗口,可按 100 个节点加载更早内容。工具累计输出覆盖同一块而不重复追加。
- Composer 支持文字、粘贴或选择 PNG/JPEG/WebP/GIF 图片。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;状态与事件只保存 attachment id时间线按需读取二进制并创建临时 object URL不保存重复 base64。
- Composer 支持文字、粘贴或选择 PNG/JPEG/WebP/GIF 图片,每条消息最多 16 张、最多并行上传 4 张。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id时间线按需读取二进制并创建临时 object URL不保存重复 base64。
- Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。共享 runtime/provider 是否真正并发执行不同 Session 尚未经过自动化真实运行 smoke 验证,运行时仍可能自行串行、限流或拒绝请求。
- 首次发送会立即生成稳定的乐观用户消息HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。
- 归档、分支、标题、未读、模型/thinking、队列、交互、子任务、changes/files/browser/skills/commands 等完整 Conversation 操作当前不属于核心聊天页。

View File

@@ -81,7 +81,18 @@ export function createCodingComposition(
});
const extensionHost = new PiManagedExtensionHost();
extensionHost.configureProductTools(productTools);
const registry = new PiSessionRegistry({ projectStore });
const conversationStores = new Map<string, ReturnType<typeof createCodingConversationStore>>();
const conversationStoreForProject = (projectPath: string) => {
const existing = conversationStores.get(projectPath);
if (existing) return existing;
const created = createCodingConversationStore(projectPath);
conversationStores.set(projectPath, created);
return created;
};
const registry = new PiSessionRegistry({
projectStore,
createConversationStore: conversationStoreForProject,
});
const revisions = new PiManagedInputRevisionCoordinator();
const processBudget = new PiProcessBudget();
const loadProviderInput = async () => ({
@@ -145,15 +156,16 @@ export function createCodingComposition(
})),
});
const projects = new CodingProjectService(projectStore, {
createConversationStore: conversationStoreForProject,
onResourcesChanged: async (project) => {
runtime.markResourcesStale();
const conversations = await createCodingConversationStore(project.path).read()
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
for (const conversation of conversations) registry.forget(conversation.id);
},
onProjectDeactivated: async (project) => {
const conversations = await createCodingConversationStore(project.path).read()
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
await Promise.allSettled([

View File

@@ -3,6 +3,7 @@ import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
const SUPPORTED_IMAGE_MIMES = new Set([
'image/png',
'image/jpeg',
@@ -10,6 +11,37 @@ const SUPPORTED_IMAGE_MIMES = new Set([
'image/gif',
]);
function matchesImageSignature(data: Uint8Array, mime: string): boolean {
if (mime === 'image/png') {
return data.length >= 8
&& data[0] === 0x89
&& data[1] === 0x50
&& data[2] === 0x4e
&& data[3] === 0x47
&& data[4] === 0x0d
&& data[5] === 0x0a
&& data[6] === 0x1a
&& data[7] === 0x0a;
}
if (mime === 'image/jpeg') {
return data.length >= 3
&& data[0] === 0xff
&& data[1] === 0xd8
&& data[2] === 0xff;
}
if (mime === 'image/gif') {
if (data.length < 6) return false;
const header = Buffer.from(data.subarray(0, 6)).toString('ascii');
return header === 'GIF87a' || header === 'GIF89a';
}
if (mime === 'image/webp') {
return data.length >= 12
&& Buffer.from(data.subarray(0, 4)).toString('ascii') === 'RIFF'
&& Buffer.from(data.subarray(8, 12)).toString('ascii') === 'WEBP';
}
return false;
}
function contentType(req: IncomingMessage): string {
const value = req.headers['content-type'];
return (Array.isArray(value) ? value[0] : value)?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
@@ -17,7 +49,7 @@ function contentType(req: IncomingMessage): string {
async function readBoundedBody(req: IncomingMessage): Promise<Uint8Array> {
const declared = Number(req.headers['content-length']);
if (Number.isFinite(declared) && (declared <= 0 || declared > MAX_ATTACHMENT_BYTES)) {
if (Number.isFinite(declared) && declared > MAX_ATTACHMENT_BYTES) {
throw Object.assign(new Error('Attachment size is invalid'), { status: 413 });
}
const chunks: Buffer[] = [];
@@ -86,12 +118,24 @@ export async function handleCodingAttachmentRoutes(
if (!SUPPORTED_IMAGE_MIMES.has(mime)) {
throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 });
}
const attachment = await attachments.put(await readBoundedBody(req), mime);
const body = await readBoundedBody(req);
if (!matchesImageSignature(body, mime)) {
throw Object.assign(new Error('Attachment body is not the declared image type'), { status: 400 });
}
const attachment = await attachments.put(body, mime);
sendJson(res, 201, attachment);
return true;
}
const attachmentId = decodeURIComponent(contentMatch?.[1] ?? '');
let attachmentId: string;
try {
attachmentId = decodeURIComponent(contentMatch?.[1] ?? '');
} catch {
throw Object.assign(new Error('Attachment id is invalid'), { status: 400 });
}
if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) {
throw Object.assign(new Error('Attachment id is invalid'), { status: 400 });
}
const record = await attachments.read(attachmentId);
if (!SUPPORTED_IMAGE_MIMES.has(record.mime)) {
throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 });

View File

@@ -56,9 +56,12 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE
}
// ── Content-Type gate (anti-CSRF) ──────────────────────────
// Mutation requests must use application/json to force a CORS
// preflight, preventing "simple request" CSRF attacks.
if (!requireJsonContentType(req)) {
// Mutation requests use application/json, except the exact authenticated
// image-byte upload route. Its supported image MIME types also force a
// browser preflight and are validated before storage.
const isCodingAttachmentUpload = req.method === 'POST'
&& requestUrl.pathname === '/api/coding/attachments';
if (!isCodingAttachmentUpload && !requireJsonContentType(req)) {
sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' });
return;
}

View File

@@ -56,6 +56,7 @@ export interface CodingProjectConfigSnapshot {
export interface CodingProjectServiceOptions {
onResourcesChanged?(project: CodingProject): Promise<void> | void;
onProjectDeactivated?(project: CodingProject): Promise<void> | void;
createConversationStore?: typeof createCodingConversationStore;
writeConfig?: typeof writeCodingProjectConfigV2;
}
@@ -299,7 +300,7 @@ export class CodingProjectService {
conversationStore(projectPath: string): ReturnType<typeof createCodingConversationStore> {
const existing = this.conversationStores.get(projectPath);
if (existing) return existing;
const created = createCodingConversationStore(projectPath);
const created = (this.options.createConversationStore ?? createCodingConversationStore)(projectPath);
this.conversationStores.set(projectPath, created);
return created;
}

View File

@@ -1,6 +1,8 @@
import { hostApiFetch, hostApiFetchBytes } from './host-api';
export const CODING_ATTACHMENT_MAX_BYTES = 16 * 1024 * 1024;
export const CODING_ATTACHMENT_MAX_COUNT = 16;
export const CODING_ATTACHMENT_UPLOAD_CONCURRENCY = 4;
export const CODING_ATTACHMENT_MIMES = new Set([
'image/png',
'image/jpeg',

View File

@@ -10,7 +10,9 @@ import {
import { Button } from '@/components/ui/button';
import {
CODING_ATTACHMENT_MAX_BYTES,
CODING_ATTACHMENT_MAX_COUNT,
CODING_ATTACHMENT_MIMES,
CODING_ATTACHMENT_UPLOAD_CONCURRENCY,
uploadCodingAttachment,
type CodingAttachmentRef,
} from '@/lib/coding-attachments';
@@ -23,7 +25,7 @@ import {
useCodingConversationStore,
type CodingConversationStoreState,
} from '@/stores/coding-conversations';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-workspace';
import type {
CodingDraftAttachment,
} from '@/types/coding-conversation';
@@ -66,6 +68,7 @@ function requestCount(
}
const BLOCKING_REQUEST_STATUSES = new Set(['pending', 'accepted']);
const SUBMITTING_REQUEST_STATUSES = new Set(['pending']);
const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']);
const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']);
@@ -96,10 +99,8 @@ export function CodingChatPanel({
const submitPrompt = useCodingConversationStore((state) => state.submitPrompt);
const recoverConversation = useCodingConversationStore((state) => state.recoverConversation);
const [provisionalDraft, setProvisionalDraft] = useState('');
const [creatingNewConversation, setCreatingNewConversation] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submissionError, setSubmissionError] = useState<string | null>(null);
const [provisionalDrafts, setProvisionalDrafts] = useState<Record<string, string>>({});
const [submissionErrors, setSubmissionErrors] = useState<Record<string, string>>({});
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
Record<string, LocalComposerAttachment[]>
>({});
@@ -108,6 +109,7 @@ export function CodingChatPanel({
const attachmentsRef = useRef(attachmentsByDraftKey);
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
const submissionFlightsRef = useRef(new Set<string>());
const agents = useMemo(() => (
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
@@ -122,11 +124,16 @@ export function CodingChatPanel({
), [conversations, selectedAgent]);
const selectedConversation = conversations.find((conversation) => (
conversation.id === selectedConversationId
&& conversation.agentId === selectedAgent?.id
&& !conversation.archivedAt
)) ?? null;
const targetConversationId = selectedConversation?.id ?? null;
const provisionalDraftKey = activeProject && selectedAgent
? `new:${activeProject.id}:${selectedAgent.id}`
: null;
const draftKey = selectedConversationId ?? provisionalDraftKey;
const draftKey = targetConversationId ?? provisionalDraftKey;
const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : '';
const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null;
const localAttachments = useMemo(() => (
draftKey ? attachmentsByDraftKey[draftKey] ?? [] : []
), [attachmentsByDraftKey, draftKey]);
@@ -134,42 +141,47 @@ export function CodingChatPanel({
attachmentsRef.current = attachmentsByDraftKey;
const selectDraft = useCallback((state: CodingConversationStoreState) => (
selectedConversationId
? selectCodingConversationDraft(selectedConversationId)(state)
targetConversationId
? selectCodingConversationDraft(targetConversationId)(state)
: null
), [selectedConversationId]);
), [targetConversationId]);
const selectedDraft = useCodingConversationStore(selectDraft);
const selectSnapshot = useCallback((state: CodingConversationStoreState) => (
selectedConversationId
? selectCodingConversationSnapshot(selectedConversationId)(state)
targetConversationId
? selectCodingConversationSnapshot(targetConversationId)(state)
: null
), [selectedConversationId]);
), [targetConversationId]);
const snapshot = useCodingConversationStore(selectSnapshot);
const entryLoadState = useCodingConversationStore((state) => (
selectedConversationId
? state.entriesByConversationId[selectedConversationId]?.loadState ?? 'empty'
targetConversationId
? state.entriesByConversationId[targetConversationId]?.loadState ?? 'empty'
: 'empty'
));
const entryError = useCodingConversationStore((state) => (
selectedConversationId
? state.entriesByConversationId[selectedConversationId]?.error ?? null
targetConversationId
? state.entriesByConversationId[targetConversationId]?.error ?? null
: null
));
const blockingRequestCount = useCodingConversationStore((state) => requestCount(
state,
selectedConversationId,
targetConversationId,
BLOCKING_REQUEST_STATUSES,
));
const acceptedRequestCount = useCodingConversationStore((state) => requestCount(
state,
selectedConversationId,
targetConversationId,
ACCEPTED_REQUEST_STATUSES,
));
const uncertainRequestCount = useCodingConversationStore((state) => requestCount(
state,
selectedConversationId,
targetConversationId,
UNCERTAIN_REQUEST_STATUSES,
));
const submittingRequestCount = useCodingConversationStore((state) => requestCount(
state,
targetConversationId,
SUBMITTING_REQUEST_STATUSES,
));
useEffect(() => {
void loadWorkspace().catch(() => undefined);
@@ -207,7 +219,11 @@ export function CodingChatPanel({
void ensureConversation(selectedAgent.id)
.then((conversation) => {
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
void selectConversation(conversation.id).catch(() => undefined);
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === activeProject.id
&& current.selectedAgentId === selectedAgent.id) {
void selectConversation(conversation.id).catch(() => undefined);
}
})
.catch(() => undefined);
}, [
@@ -225,30 +241,38 @@ export function CodingChatPanel({
const nextDraft = navigationDraft?.trim();
if (!nextDraft || appliedNavigationDraftRef.current === nextDraft) return;
appliedNavigationDraftRef.current = nextDraft;
if (selectedConversationId) {
setConversationDraft(selectedConversationId, nextDraft);
if (targetConversationId) {
setConversationDraft(targetConversationId, nextDraft);
} else if (provisionalDraftKey) {
setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: nextDraft }));
} else {
setProvisionalDraft(nextDraft);
return;
}
}, [navigationDraft, selectedConversationId, setConversationDraft]);
}, [navigationDraft, provisionalDraftKey, setConversationDraft, targetConversationId]);
useEffect(() => {
if (!selectedConversationId) return;
if (!targetConversationId) return;
if (provisionalDraft.trim()) {
const current = codingConversationStore.getState().draftsByConversationId[selectedConversationId];
if (!current?.text) setConversationDraft(selectedConversationId, provisionalDraft);
setProvisionalDraft('');
const current = codingConversationStore.getState().draftsByConversationId[targetConversationId];
if (!current?.text) setConversationDraft(targetConversationId, provisionalDraft);
if (provisionalDraftKey) {
setProvisionalDrafts((drafts) => {
const next = { ...drafts };
delete next[provisionalDraftKey];
return next;
});
}
}
if (provisionalDraftKey) {
setAttachmentsByDraftKey((currentAttachments) => {
const pending = currentAttachments[provisionalDraftKey];
if (!pending?.length || currentAttachments[selectedConversationId]?.length) return currentAttachments;
const next = { ...currentAttachments, [selectedConversationId]: pending };
if (!pending?.length || currentAttachments[targetConversationId]?.length) return currentAttachments;
const next = { ...currentAttachments, [targetConversationId]: pending };
delete next[provisionalDraftKey];
return next;
});
}
}, [provisionalDraft, provisionalDraftKey, selectedConversationId, setConversationDraft]);
}, [provisionalDraft, provisionalDraftKey, setConversationDraft, targetConversationId]);
const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => {
if (!activeProject) return;
@@ -257,19 +281,20 @@ export function CodingChatPanel({
}, [activeProject, primeConversation, selectConversation]);
const handleCreateConversation = useCallback(async () => {
if (!activeProject || !selectedAgent || creatingNewConversation) return;
setCreatingNewConversation(true);
try {
const conversation = await createConversation(selectedAgent.id);
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return;
const projectId = activeProject.id;
const agentId = selectedAgent.id;
const conversation = await createConversation(agentId).catch(() => null);
if (!conversation) return;
primeConversation(createLocalConversationSnapshot(projectId, conversation));
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === projectId && current.selectedAgentId === agentId) {
await selectConversation(conversation.id).catch(() => undefined);
} finally {
setCreatingNewConversation(false);
}
}, [
activeProject,
createConversation,
creatingNewConversation,
creatingAgentIds,
primeConversation,
selectConversation,
selectedAgent,
@@ -278,14 +303,18 @@ export function CodingChatPanel({
const handleAddFiles = useCallback((files: File[]) => {
if (!draftKey) return;
const accepted: LocalComposerAttachment[] = [];
for (const file of files) {
const remaining = Math.max(0, CODING_ATTACHMENT_MAX_COUNT - localAttachments.length);
let validationError = files.length > remaining
? `每条消息最多添加 ${CODING_ATTACHMENT_MAX_COUNT} 张图片。`
: null;
for (const file of files.slice(0, remaining)) {
const mime = file.type.trim().toLowerCase();
if (!CODING_ATTACHMENT_MIMES.has(mime)) {
setSubmissionError('仅支持 PNG、JPEG、WebP 或 GIF 图片。');
validationError = '仅支持 PNG、JPEG、WebP 或 GIF 图片。';
continue;
}
if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) {
setSubmissionError('图片不能超过 16 MB。');
validationError = '图片不能超过 16 MB。';
continue;
}
accepted.push({
@@ -295,13 +324,18 @@ export function CodingChatPanel({
previewUrl: URL.createObjectURL(file),
});
}
setSubmissionErrors((current) => {
const next = { ...current };
if (validationError) next[draftKey] = validationError;
else delete next[draftKey];
return next;
});
if (accepted.length === 0) return;
setSubmissionError(null);
setAttachmentsByDraftKey((current) => ({
...current,
[draftKey]: [...(current[draftKey] ?? []), ...accepted],
}));
}, [draftKey]);
}, [draftKey, localAttachments.length]);
const handleRemoveAttachment = useCallback((id: string) => {
if (!draftKey) return;
@@ -319,37 +353,54 @@ export function CodingChatPanel({
const prepareAttachments = useCallback(async (
attachments: LocalComposerAttachment[],
): Promise<CodingDraftAttachment[]> => await Promise.all(attachments.map(async (attachment) => {
let uploaded = uploadedAttachmentsRef.current.get(attachment.id);
if (!uploaded) {
let flight = uploadFlightsRef.current.get(attachment.id);
if (!flight) {
flight = uploadCodingAttachment(attachment.file);
uploadFlightsRef.current.set(attachment.id, flight);
}
try {
uploaded = await flight;
uploadedAttachmentsRef.current.set(attachment.id, uploaded);
} finally {
if (uploadFlightsRef.current.get(attachment.id) === flight) {
uploadFlightsRef.current.delete(attachment.id);
): Promise<CodingDraftAttachment[]> => {
const prepared = new Array<CodingDraftAttachment>(attachments.length);
let nextIndex = 0;
const workers = Array.from({
length: Math.min(CODING_ATTACHMENT_UPLOAD_CONCURRENCY, attachments.length),
}, async () => {
while (nextIndex < attachments.length) {
const index = nextIndex++;
const attachment = attachments[index];
let uploaded = uploadedAttachmentsRef.current.get(attachment.id);
if (!uploaded) {
let flight = uploadFlightsRef.current.get(attachment.id);
if (!flight) {
flight = uploadCodingAttachment(attachment.file);
uploadFlightsRef.current.set(attachment.id, flight);
}
try {
uploaded = await flight;
uploadedAttachmentsRef.current.set(attachment.id, uploaded);
} finally {
if (uploadFlightsRef.current.get(attachment.id) === flight) {
uploadFlightsRef.current.delete(attachment.id);
}
}
}
prepared[index] = {
attachmentId: uploaded.attachmentId,
mime: uploaded.mime,
previewUrl: attachment.previewUrl,
};
}
}
return {
attachmentId: uploaded.attachmentId,
mime: uploaded.mime,
previewUrl: attachment.previewUrl,
};
})), []);
});
await Promise.all(workers);
return prepared;
}, []);
const handleSubmit = useCallback(() => {
if (!selectedConversationId || !draftKey || submitting) return;
if (!targetConversationId || !draftKey || submissionFlightsRef.current.has(targetConversationId)) return;
const conversationId = targetConversationId;
const attachments = [...localAttachments];
setSubmitting(true);
setSubmissionError(null);
submissionFlightsRef.current.add(conversationId);
setSubmissionErrors((current) => {
const next = { ...current };
delete next[draftKey];
return next;
});
void submitPrompt({
conversationId: selectedConversationId,
conversationId,
mode: 'prompt',
prepareAttachments: attachments.length > 0
? () => prepareAttachments(attachments)
@@ -365,17 +416,19 @@ export function CodingChatPanel({
uploadedAttachmentsRef.current.delete(attachment.id);
}
}).catch((error) => {
setSubmissionError(error instanceof Error ? error.message : String(error));
setSubmissionErrors((current) => ({
...current,
[draftKey]: error instanceof Error ? error.message : String(error),
}));
}).finally(() => {
setSubmitting(false);
submissionFlightsRef.current.delete(conversationId);
});
}, [
draftKey,
localAttachments,
prepareAttachments,
selectedConversationId,
targetConversationId,
submitPrompt,
submitting,
]);
const runStatus = snapshot?.run.status ?? 'idle';
@@ -392,10 +445,10 @@ export function CodingChatPanel({
const editable = Boolean(activeProject && selectedAgent);
const canSend = Boolean(
editable
&& selectedConversationId
&& targetConversationId
&& (draft.trim() || localAttachments.length > 0)
&& !running
&& !submitting
&& submittingRequestCount === 0
&& !preparationError
&& blockingRequestCount === 0
&& uncertainRequestCount === 0,
@@ -439,7 +492,10 @@ export function CodingChatPanel({
'flex min-h-10 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
selectedAgent?.id === agent.id ? 'bg-background font-medium shadow-soft' : 'hover:bg-background/70',
)}
onClick={() => selectAgent(agent.id)}
onClick={() => {
clearConversationSelection();
selectAgent(agent.id);
}}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background">
{agent.name.trim().slice(0, 1) || 'A'}
@@ -456,10 +512,10 @@ export function CodingChatPanel({
size="icon"
className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]"
aria-label="新建对话"
disabled={!selectedAgent || creatingNewConversation}
disabled={!selectedAgent || Boolean(selectedAgent && creatingAgentIds[selectedAgent.id])}
onClick={() => void handleCreateConversation()}
>
{creatingNewConversation
{selectedAgent && creatingAgentIds[selectedAgent.id]
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
: <MessageSquarePlus className="h-4 w-4" aria-hidden="true" />}
</Button>
@@ -471,7 +527,7 @@ export function CodingChatPanel({
type="button"
className={cn(
'min-h-10 w-full truncate rounded-xl px-3 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
selectedConversationId === conversation.id
targetConversationId === conversation.id
? 'bg-background font-medium shadow-soft'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
@@ -530,11 +586,11 @@ export function CodingChatPanel({
</div>
)}
{selectedConversationId
{targetConversationId
? (
<CodingConversationTimeline
key={selectedConversationId}
conversationId={selectedConversationId}
key={targetConversationId}
conversationId={targetConversationId}
/>
)
: (
@@ -552,17 +608,25 @@ export function CodingChatPanel({
recoverableError={!submissionError && Boolean(preparationError)}
acceptedCount={acceptedRequestCount}
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
submitting={submitting}
submitting={submittingRequestCount > 0}
placeholder={selectedAgent ? '给当前对话发送消息' : '请选择一个伙伴后再发送'}
onChange={(value) => {
setSubmissionError(null);
if (selectedConversationId) setConversationDraft(selectedConversationId, value);
else setProvisionalDraft(value);
if (draftKey) {
setSubmissionErrors((current) => {
const next = { ...current };
delete next[draftKey];
return next;
});
}
if (targetConversationId) setConversationDraft(targetConversationId, value);
else if (provisionalDraftKey) {
setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: value }));
}
}}
onSubmit={handleSubmit}
onRecover={() => {
if (selectedConversationId) {
void recoverConversation(selectedConversationId).catch(() => undefined);
if (targetConversationId) {
void recoverConversation(targetConversationId).catch(() => undefined);
}
}}
onAddFiles={handleAddFiles}

View File

@@ -338,6 +338,7 @@ export function createCodingConversationStore(
const deps = { ...defaultDependencies(), ...dependencies };
const snapshotLoads = new Map<string, Promise<ConversationSnapshot>>();
const recoveryBatches = new Map<string, CodingConversationPatchBatchEvent[]>();
const recoveryRefreshKeys = new Map<string, string>();
let eventSource: EventSource | null = null;
let connectFlight: Promise<void> | null = null;
let connectionGeneration = 0;
@@ -375,11 +376,20 @@ export function createCodingConversationStore(
const items = [...itemsBySeq.values()].sort((left, right) => left.seq - right.seq);
if (items.length === 0) {
recoveryBatches.delete(conversationId);
recoveryRefreshKeys.delete(conversationId);
return;
}
const continuous = items[0].seq === snapshot.cursor.seq + 1
&& items.every((item, index) => index === 0 || item.seq === items[index - 1].seq + 1);
if (!continuous) {
const lastSeq = items.at(-1)?.seq ?? items[0].seq;
const refreshKey = [
snapshot.cursor.workerGeneration,
snapshot.cursor.seq,
items[0].seq,
lastSeq,
].join(':');
const willRefresh = recoveryRefreshKeys.get(conversationId) !== refreshKey;
store.setState((state) => {
const current = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {
@@ -387,15 +397,24 @@ export function createCodingConversationStore(
...state.entriesByConversationId,
[conversationId]: {
...current,
loadState: 'recovering',
loadState: willRefresh ? 'recovering' : 'error',
error: 'Conversation patch batch is not continuous',
},
},
};
});
if (willRefresh) {
recoveryRefreshKeys.set(conversationId, refreshKey);
queueMicrotask(() => {
if (!snapshotLoads.has(conversationId)) {
void store.getState().loadSnapshot(conversationId, true).catch(() => undefined);
}
});
}
return;
}
recoveryBatches.delete(conversationId);
recoveryRefreshKeys.delete(conversationId);
store.getState().applyPatchBatchEvent({
type: 'patch-batch',
conversationId,
@@ -521,6 +540,7 @@ export function createCodingConversationStore(
},
async recoverConversation(conversationId) {
recoveryRefreshKeys.delete(conversationId);
set((state) => {
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {

View File

@@ -5,9 +5,16 @@ type CapturedRequest = {
path: string;
method: string;
body?: Record<string, unknown>;
byteLength?: number;
contentType?: string;
at: number;
};
type HostConnection = {
baseUrl: string;
token: string;
};
async function disableCodingEventSource(page: Page): Promise<void> {
await page.addInitScript(() => {
class LocalEventSource extends EventTarget {
@@ -43,8 +50,11 @@ async function disableCodingEventSource(page: Page): Promise<void> {
});
}
async function installCodingFirstChatHost(electronApp: ElectronApplication): Promise<void> {
await electronApp.evaluate(async () => {
async function installCodingFirstChatHost(
electronApp: ElectronApplication,
hostConnection: HostConnection,
): Promise<void> {
await electronApp.evaluate(async (_, connection) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = {
captured: CapturedRequest[];
@@ -148,14 +158,71 @@ async function installCodingFirstChatHost(electronApp: ElectronApplication): Pro
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (
_event,
request: { path?: string; method?: string; body?: unknown },
request: {
path?: string;
method?: string;
headers?: Record<string, string>;
body?: unknown;
},
) => {
const path = request.path ?? '';
const method = request.method ?? 'GET';
const body = typeof request.body === 'string' && request.body
? JSON.parse(request.body) as Record<string, unknown>
: undefined;
state.captured.push({ path, method, ...(body ? { body } : {}), at: Date.now() });
const binaryBody = request.body instanceof ArrayBuffer
? new Uint8Array(request.body)
: ArrayBuffer.isView(request.body)
? new Uint8Array(
request.body.buffer,
request.body.byteOffset,
request.body.byteLength,
)
: undefined;
state.captured.push({
path,
method,
...(body ? { body } : {}),
...(binaryBody ? { byteLength: binaryBody.byteLength } : {}),
...(request.headers?.['Content-Type']
? { contentType: request.headers['Content-Type'] }
: {}),
at: Date.now(),
});
if (path === '/api/coding/attachments'
|| /^\/api\/coding\/attachments\/[^/]+\/content$/.test(path)) {
const response = await fetch(`${connection.baseUrl}${path}`, {
method,
headers: {
Authorization: `Bearer ${connection.token}`,
...(request.headers ?? {}),
},
...(binaryBody ? { body: binaryBody } : {}),
});
const responseContentType = response.headers.get('content-type') ?? '';
if (responseContentType.includes('application/json')) {
return {
ok: true,
data: {
status: response.status,
ok: response.ok,
json: await response.json(),
transport: 'loopback',
},
};
}
return {
ok: true,
data: {
status: response.status,
ok: response.ok,
bytes: new Uint8Array(await response.arrayBuffer()),
contentType: responseContentType.split(';', 1)[0]?.trim(),
transport: 'loopback',
},
};
}
if (path === '/api/coding/projects') {
return respond({ projects: [project], activeProjectId: project.id });
@@ -205,7 +272,7 @@ async function installCodingFirstChatHost(electronApp: ElectronApplication): Pro
}
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
});
});
}, hostConnection);
}
async function readState(electronApp: ElectronApplication): Promise<{
@@ -239,8 +306,12 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
launchElectronApp,
}) => {
const electronApp = await launchElectronApp({ skipSetup: true });
await installCodingFirstChatHost(electronApp);
let page = await getStableWindow(electronApp);
const hostConnection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, hostConnection);
await disableCodingEventSource(page);
try {
@@ -260,6 +331,15 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
));
expect(editableMs).toBeLessThan(500);
const pixelPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
await page.getByTestId('coding-file-attachment-input').setInputFiles({
name: 'pixel.png',
mimeType: 'image/png',
buffer: pixelPng,
});
await composer.fill('Build the first PI scene');
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled();
await page.getByTestId('coding-message-composer').evaluate(
@@ -279,7 +359,28 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
await expect(
page.getByTestId('coding-conversation-timeline').getByText('Build the first PI scene'),
).toBeVisible();
await expect(page.getByRole('img', { name: '对话图片附件' })).toBeVisible();
await expect(page.getByText('1 条消息已被本地 Agent 接收。')).toBeVisible();
const state = await readState(electronApp);
const uploads = state.captured.filter((request) => (
request.path === '/api/coding/attachments' && request.method === 'POST'
));
expect(uploads).toHaveLength(1);
expect(uploads[0]).toMatchObject({
byteLength: pixelPng.byteLength,
contentType: 'image/png',
});
expect(state.captured.some((request) => (
/^\/api\/coding\/attachments\/[^/]+\/content$/.test(request.path)
&& request.method === 'GET'
))).toBe(true);
const prompt = state.captured.find((request) => (
request.path === '/api/coding/conversations/conversation-pi-first-chat/prompt'
));
expect(prompt?.body?.attachments).toEqual([
{ attachmentId: expect.any(String) },
]);
expect(JSON.stringify(state.captured)).not.toContain(pixelPng.toString('base64'));
} finally {
await releaseSnapshot(electronApp);
}

View File

@@ -1,9 +1,11 @@
import { createServer, request as httpRequest } from 'node:http';
import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { handleCodingAttachmentRoutes } from '../../electron/api/routes/coding-attachments';
import { getHostApiToken, startHostApiServer } from '../../electron/api/server';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
import type { HostApiContext } from '../../electron/api/context';
@@ -36,6 +38,29 @@ async function startAttachmentServer(maxBytes = 16 * 1024 * 1024) {
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => await new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((error) => error ? reject(error) : resolve());
}),
};
}
async function startAuthenticatedHostServer() {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-host-attachments-'));
roots.push(root);
const attachments = new CodingAttachmentStore(root, { createId: () => 'host-attachment-1' });
const context = {
opencodeManager: { getStatus: () => ({ url: null }) },
codingProducts: { attachments },
} as unknown as HostApiContext;
const server = startHostApiServer(context, 0);
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Host API test server failed');
return {
baseUrl: `http://127.0.0.1:${address.port}`,
token: getHostApiToken(),
close: async () => await new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((error) => error ? reject(error) : resolve());
}),
};
@@ -66,10 +91,43 @@ async function postDeclaredLength(
}
describe('coding attachment routes', () => {
it('accepts authenticated binary uploads through the production Host API gate', async () => {
const server = await startAuthenticatedHostServer();
try {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1]);
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${server.token}`,
'Content-Type': 'image/png',
},
body: bytes,
});
expect(upload.status).toBe(201);
await expect(upload.json()).resolves.toEqual({
attachmentId: 'host-attachment-1',
mime: 'image/png',
byteLength: bytes.byteLength,
});
const unrelatedMutation = await fetch(`${server.baseUrl}/api/coding/projects`, {
method: 'POST',
headers: {
Authorization: `Bearer ${server.token}`,
'Content-Type': 'image/png',
},
body: bytes,
});
expect(unrelatedMutation.status).toBe(415);
} finally {
await server.close();
}
});
it('round-trips bounded image bytes through attachment ids', async () => {
const server = await startAttachmentServer();
try {
const bytes = new Uint8Array([137, 80, 78, 71, 1, 2, 3]);
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
method: 'POST',
headers: { 'Content-Type': 'image/png' },
@@ -119,6 +177,42 @@ describe('coding attachment routes', () => {
}
});
it('rejects bytes that do not match the declared image MIME', async () => {
const server = await startAttachmentServer();
try {
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
method: 'POST',
headers: { 'Content-Type': 'image/png' },
body: 'not actually a png',
});
expect(upload.status).toBe(400);
await expect(upload.json()).resolves.toMatchObject({
code: 'CODING_ATTACHMENT_INVALID',
});
} finally {
await server.close();
}
});
it.each([
['image/jpeg', new Uint8Array([0xff, 0xd8, 0xff, 0x01])],
['image/gif', new TextEncoder().encode('GIF89a!')],
['image/webp', new TextEncoder().encode('RIFF\x04\x00\x00\x00WEBP')],
])('accepts the minimal %s image signature', async (mime, bytes) => {
const server = await startAttachmentServer();
try {
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
method: 'POST',
headers: { 'Content-Type': mime },
body: bytes,
});
expect(upload.status).toBe(201);
await expect(upload.json()).resolves.toMatchObject({ mime });
} finally {
await server.close();
}
});
it('rejects a declared body larger than the route limit', async () => {
const server = await startAttachmentServer();
try {
@@ -134,4 +228,28 @@ describe('coding attachment routes', () => {
await server.close();
}
});
it('maps empty bodies and invalid opaque ids to stable client errors', async () => {
const server = await startAttachmentServer();
try {
const empty = await fetch(`${server.baseUrl}/api/coding/attachments`, {
method: 'POST',
headers: { 'Content-Type': 'image/png' },
});
expect(empty.status).toBe(400);
await expect(empty.json()).resolves.toMatchObject({
code: 'CODING_ATTACHMENT_INVALID',
});
for (const id of ['bad%2Fid', '%ZZ']) {
const invalid = await fetch(`${server.baseUrl}/api/coding/attachments/${id}/content`);
expect(invalid.status).toBe(400);
await expect(invalid.json()).resolves.toMatchObject({
code: 'CODING_ATTACHMENT_INVALID',
});
}
} finally {
await server.close();
}
});
});

View File

@@ -1,7 +1,7 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ConversationSnapshot } from '@/types/coding-conversation';
import type {
CodingConversationMetadata,
@@ -58,7 +58,7 @@ const agent: CodingProjectAgent = {
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
model: null,
modelResolution: 'default',
modelResolution: 'required',
};
const config: CodingProjectConfig = {
schemaVersion: 2,
@@ -79,8 +79,24 @@ const conversation: CodingConversationMetadata = {
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
model: null,
modelResolution: 'default',
modelResolution: 'required',
};
const reviewer: CodingProjectAgent = {
...agent,
id: 'agent-2',
name: 'Reviewer',
pinned: false,
};
const reviewerConversation: CodingConversationMetadata = {
...conversation,
id: 'conversation-2',
agentId: reviewer.id,
title: 'Reviewer conversation',
};
function configForAgents(agents: CodingProjectAgent[]): CodingProjectConfig {
return { ...config, agents };
}
const projectApi = vi.hoisted(() => ({
list: vi.fn(),
@@ -94,6 +110,7 @@ const conversationApi = vi.hoisted(() => ({
submit: vi.fn(),
recover: vi.fn(),
}));
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
vi.mock('@/lib/coding-projects', () => ({
listCodingProjects: projectApi.list,
@@ -109,8 +126,28 @@ vi.mock('@/lib/coding-conversations', () => ({
recoverCodingConversation: conversationApi.recover,
}));
vi.mock('@/lib/coding-attachments', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/coding-attachments')>(),
uploadCodingAttachment: attachmentApi.upload,
}));
describe('CodingChatPanel first Conversation', () => {
afterEach(() => vi.restoreAllMocks());
beforeEach(() => {
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((file: File) => `blob:${file.name}`),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.resetModules();
});
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
@@ -166,6 +203,168 @@ describe('CodingChatPanel first Conversation', () => {
}
});
it('does not let a slow first-Conversation completion steal selection after an Agent switch', async () => {
const slowCreate = deferred<CodingConversationMetadata>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([reviewerConversation]);
projectApi.create.mockReturnValue(slowCreate.promise);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
project.id,
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
)
));
render(<CodingChatPanel />);
await screen.findByRole('textbox');
await waitFor(() => expect(projectApi.create).toHaveBeenCalledWith({
projectId: project.id,
agentId: agent.id,
title: '新对话',
}));
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
await act(async () => {
slowCreate.resolve(conversation);
await slowCreate.promise;
});
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
});
it('keeps submission state and rejection scoped to the originating Conversation', async () => {
const pendingSubmit = deferred<never>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.submit.mockReturnValueOnce(pendingSubmit.promise);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
project.id,
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
)
));
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
fireEvent.change(textbox, { target: { value: 'A prompt' } });
await waitFor(() => expect(
codingConversationStore.getState().draftsByConversationId[conversation.id]?.text,
).toBe('A prompt'));
expect(codingConversationStore.getState().entriesByConversationId[conversation.id])
.toMatchObject({ loadState: 'live', error: null });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'B prompt' } });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
await act(async () => {
pendingSubmit.reject(new Error('A submission failed'));
await pendingSubmit.promise.catch(() => undefined);
});
expect(screen.queryByText('A submission failed')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toHaveValue('B prompt');
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
});
it('caps one message at 16 images and uploads at most four concurrently', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.submit.mockImplementation(async (input: {
conversationId: string;
clientRequestId: string;
mode: 'prompt';
}) => ({
accepted: true,
conversationId: input.conversationId,
clientRequestId: input.clientRequestId,
runId: 'run-1',
mode: input.mode,
}));
const uploadFlights: Array<ReturnType<typeof deferred<{
attachmentId: string;
mime: string;
byteLength: number;
}>>> = [];
let activeUploads = 0;
let maxActiveUploads = 0;
attachmentApi.upload.mockImplementation((file: File) => {
const flight = deferred<{ attachmentId: string; mime: string; byteLength: number }>();
uploadFlights.push(flight);
activeUploads += 1;
maxActiveUploads = Math.max(maxActiveUploads, activeUploads);
return flight.promise.finally(() => {
activeUploads -= 1;
}).then(() => ({
attachmentId: `attachment-${file.name}`,
mime: file.type,
byteLength: file.size,
}));
});
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
await screen.findByRole('textbox');
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
const files = Array.from({ length: 17 }, (_, index) => new File(
[new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, index])],
`image-${index}.png`,
{ type: 'image/png' },
));
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
target: { files },
});
expect(await screen.findByText('每条消息最多添加 16 张图片。')).toBeInTheDocument();
expect(screen.getAllByRole('img')).toHaveLength(16);
expect(codingConversationStore.getState().entriesByConversationId[conversation.id])
.toMatchObject({ loadState: 'live', error: null });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledTimes(4));
let resolved = 0;
while (resolved < 16) {
const available = uploadFlights.slice(resolved);
for (const flight of available) flight.resolve({
attachmentId: `resolved-${resolved++}`,
mime: 'image/png',
byteLength: 9,
});
if (resolved < 16) {
await waitFor(() => expect(uploadFlights.length).toBeGreaterThan(resolved));
}
}
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
expect(attachmentApi.upload).toHaveBeenCalledTimes(16);
expect(maxActiveUploads).toBeLessThanOrEqual(4);
});
it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => {
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
const onSubmit = vi.fn();

View File

@@ -0,0 +1,235 @@
import { act, render } from '@testing-library/react';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Profiler } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { HostApiContext } from '../../electron/api/context';
import { handleCodingConversationRoutes } from '../../electron/api/routes/coding-conversations';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
import {
createCodingProjectStore,
createLocalCodingProject,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import { CodingConversationService } from '../../electron/coding-runtime/conversation-service';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import type {
CodingConversationPatchBatchEvent,
CodingConversationSnapshotEvent,
ConversationPatch,
ConversationPatchEnvelope,
} from '../../electron/coding-runtime/contracts';
import { CodingConversationTimeline } from '../../src/pages/Chat/CodingConversationTimeline';
import { codingConversationStore } from '../../src/stores/coding-conversations';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function percentile95(values: number[]): number {
const ordered = [...values].sort((left, right) => left - right);
return ordered[Math.ceil(ordered.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY;
}
function pressurePatch(seq: number): ConversationPatch {
if (seq === 1) {
return {
op: 'message.upsert',
node: {
kind: 'message',
id: 'assistant-pressure',
role: 'assistant',
status: 'streaming',
blocks: [
{ kind: 'text', id: 'answer-pressure', text: 'Mixed answer', status: 'complete' },
{ kind: 'thinking', id: 'thinking-pressure', text: '', status: 'streaming' },
],
},
};
}
if (seq === 2) {
return {
op: 'tool.upsert',
node: {
kind: 'tool',
id: 'tool-pressure',
toolCallId: 'call-pressure',
toolName: 'read',
title: 'Pressure fixture tool output',
inputText: 'fixture.txt',
status: 'complete',
output: [{
kind: 'text',
id: 'tool-pressure-output',
text: 'T'.repeat(4 * 1024),
status: 'complete',
}],
},
};
}
return {
op: 'message.block-delta',
messageId: 'assistant-pressure',
blockId: 'thinking-pressure',
delta: `${seq}:`.padEnd(1_100, 'x'),
};
}
describe('REN-008 coding timeline pressure', () => {
it('batches 100 KB of mixed output and keeps Main-to-React p95 within 50 ms', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-pressure-'));
roots.push(root);
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-pressure',
now: () => '2026-08-24T00:00:00.000Z',
});
await createLocalCodingProject({
projectPath: root,
now: '2026-08-24T00:00:00.000Z',
}, projectStore);
await createCodingProjectAgent(root, {
id: 'builder',
avatarId: 'avatar-01',
roleName: '实现者',
name: 'Builder',
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
responsibility: {
mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [],
},
}, { now: '2026-08-24T00:00:00.000Z' });
const runtime = new InMemoryConversationRuntime();
const projects = new CodingProjectService(projectStore);
const conversations = new CodingConversationService(projects, runtime, {
deliveryBatchWindowMs: 24,
});
const conversation = await conversations.createConversation({
agentId: 'builder',
title: 'Pressure',
});
let publish: ((event: ConversationPatchEnvelope) => void) | undefined;
vi.spyOn(runtime, 'subscribe').mockImplementation((listener) => {
publish = listener;
return () => undefined;
});
const context = {
codingProducts: { projects, conversations, runtime },
} as unknown as HostApiContext;
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
void handleCodingConversationRoutes(request, response, url, context);
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Pressure server did not bind');
const controller = new AbortController();
const response = await fetch(
`http://127.0.0.1:${address.port}/api/coding/events?conversationId=${conversation.id}`,
{ signal: controller.signal },
);
const reader = response.body?.getReader();
if (!reader) throw new Error('Pressure SSE response has no body');
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffered = '';
let wireBytes = 0;
let sseFrames = 0;
const nextFrame = async (): Promise<{ event: string; data: unknown }> => {
while (!buffered.includes('\n\n')) {
const next = await reader.read();
if (next.done) throw new Error('Pressure SSE ended early');
buffered += decoder.decode(next.value, { stream: true });
}
const boundary = buffered.indexOf('\n\n');
const frame = buffered.slice(0, boundary);
buffered = buffered.slice(boundary + 2);
wireBytes += encoder.encode(`${frame}\n\n`).byteLength;
sseFrames += 1;
const event = frame.split('\n').find((line) => line.startsWith('event: '))?.slice(7) ?? '';
const data = frame.split('\n').find((line) => line.startsWith('data: '))?.slice(6) ?? 'null';
return { event, data: JSON.parse(data) as unknown };
};
try {
const initial = await nextFrame();
expect(initial.event).toBe('snapshot');
codingConversationStore.getState().applySnapshotEvent(
initial.data as CodingConversationSnapshotEvent,
);
let rendererTransactions = 0;
const unsubscribe = codingConversationStore.subscribe(() => {
rendererTransactions += 1;
});
let reactCommits = 0;
const view = render(
<Profiler id="pressure-timeline" onRender={() => { reactCommits += 1; }}>
<CodingConversationTimeline conversationId={conversation.id} />
</Profiler>,
);
const initialCommits = reactCommits;
if (!publish) throw new Error('Pressure runtime subscriber was not installed');
const latencies: number[] = [];
let runtimePatchItems = 0;
let patchBatches = 0;
for (let burst = 0; burst < 20; burst += 1) {
const startedAt = performance.now();
for (let offset = 0; offset < 5; offset += 1) {
const seq = burst * 5 + offset + 1;
runtimePatchItems += 1;
publish({
conversationId: conversation.id,
workerGeneration: 0,
seq,
at: Date.now(),
patch: pressurePatch(seq),
});
}
const frame = await nextFrame();
expect(frame.event).toBe('patch-batch');
const batch = frame.data as CodingConversationPatchBatchEvent;
patchBatches += 1;
expect(batch.items).toHaveLength(5);
await act(async () => {
codingConversationStore.getState().applyPatchBatchEvent(batch);
});
latencies.push(performance.now() - startedAt);
}
const measuredReactCommits = reactCommits - initialCommits;
const p95Ms = percentile95(latencies);
const metrics = {
runtimePatchItems,
patchBatches,
sseFrames,
rendererTransactions,
reactCommits: measuredReactCommits,
wireBytes,
mainToReactP95Ms: p95Ms,
};
console.info('REN-008 metrics', metrics);
expect(metrics.runtimePatchItems).toBe(100);
expect(metrics.wireBytes).toBeGreaterThan(100 * 1024);
expect(metrics.patchBatches).toBeLessThan(metrics.runtimePatchItems);
expect(metrics.rendererTransactions).toBe(metrics.patchBatches);
expect(metrics.reactCommits).toBeLessThan(metrics.runtimePatchItems);
expect(metrics.sseFrames).toBe(metrics.patchBatches + 1);
expect(metrics.mainToReactP95Ms).toBeLessThanOrEqual(50);
expect(codingConversationStore.getState()
.entriesByConversationId[conversation.id]?.reducer.snapshot?.cursor.seq).toBe(100);
view.unmount();
unsubscribe();
} finally {
controller.abort();
await reader.cancel().catch(() => undefined);
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});

View File

@@ -705,6 +705,74 @@ describe('coding Conversation store', () => {
});
});
it('refreshes a target Snapshot once more when the first recovery response is still behind', async () => {
const firstRecovery = deferred<ConversationSnapshot>();
const secondRecovery = deferred<ConversationSnapshot>();
const getSnapshot = vi.fn()
.mockImplementationOnce(() => firstRecovery.promise)
.mockImplementationOnce(() => secondRecovery.promise);
const store = createCodingConversationStore({
getSnapshot,
openEvents: vi.fn(),
submitPrompt: vi.fn(),
createId: ids(),
});
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
store.getState().applyPatchBatchEvent(patchBatch('conversation-a', 3, [
{ op: 'run.state', run: { status: 'running', runId: 'run-a' } },
{
op: 'context.replace',
context: { usedTokens: 20, contextWindow: 100, compaction: 'idle' },
},
]));
firstRecovery.resolve(snapshot('conversation-a', 1, 1));
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2));
expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('recovering');
secondRecovery.resolve(snapshot('conversation-a', 1, 2));
await waitFor(() => {
expect(selectCodingConversationSnapshot('conversation-a')(store.getState()))
.toMatchObject({
cursor: { workerGeneration: 1, seq: 4 },
context: { usedTokens: 20, contextWindow: 100 },
run: { status: 'running', runId: 'run-a' },
});
});
expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('live');
});
it('returns a repeatedly stale recovery Snapshot to a retryable error state', async () => {
const firstRecovery = deferred<ConversationSnapshot>();
const secondRecovery = deferred<ConversationSnapshot>();
const getSnapshot = vi.fn()
.mockImplementationOnce(() => firstRecovery.promise)
.mockImplementationOnce(() => secondRecovery.promise);
const store = createCodingConversationStore({
getSnapshot,
openEvents: vi.fn(),
submitPrompt: vi.fn(),
createId: ids(),
});
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 3, {
op: 'run.state',
run: { status: 'running', runId: 'run-a' },
}));
firstRecovery.resolve(snapshot('conversation-a', 1, 1));
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2));
secondRecovery.resolve(snapshot('conversation-a', 1, 1));
await waitFor(() => {
expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({
loadState: 'error',
error: 'Conversation patch batch is not continuous',
});
});
expect(getSnapshot).toHaveBeenCalledTimes(2);
});
it('does not rerender a selected timeline when a hidden Conversation streams', () => {
const store = createCodingConversationStore({
getSnapshot: vi.fn(),

View File

@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
import {
createCodingProjectStore,
@@ -170,6 +171,74 @@ describe('Pi session registry', () => {
]));
});
it('shares one project mutation queue with ProjectService metadata writes', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-shared-conversations-'));
roots.push(projectPath);
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-shared',
now: () => NOW,
});
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
await createCodingProjectAgent(projectPath, {
id: 'agent-a',
avatarId: 'avatar-01',
roleName: 'Implementer',
name: 'Agent A',
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [] },
}, { now: NOW });
const conversationIds = [
'f47ac10b-58cc-4372-a567-0e02b2c3d484',
'f47ac10b-58cc-4372-a567-0e02b2c3d485',
];
const sharedStore = createCodingConversationStore(projectPath, {
createId: () => conversationIds.shift() as string,
now: () => NOW,
});
const left = await sharedStore.create({
agentId: 'agent-a',
title: 'Left',
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
});
const sharedProvider = vi.fn(() => sharedStore);
const projects = new CodingProjectService(projectStore, {
createConversationStore: sharedProvider,
});
const registry = new PiSessionRegistry({
projectStore,
createConversationStore: sharedProvider,
});
await Promise.all([
registry.ensureBinding({
conversationId: left.id,
projectId: 'project-shared',
agentId: 'agent-a',
title: left.title,
model: { model: left.model, modelResolution: left.modelResolution },
}, async () => ({ piSessionId: 'pi-left', sessionKey: 'key-left' })),
projects.conversationStore(projectPath).create({
agentId: 'agent-a',
title: 'Right',
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
}),
]);
const persisted = await sharedStore.read();
expect(projects.conversationStore(projectPath)).toBe(sharedStore);
expect(persisted.conversations).toEqual(expect.arrayContaining([
expect.objectContaining({
id: left.id,
piSessionId: 'pi-left',
sessionKey: 'key-left',
}),
expect.objectContaining({ title: 'Right', agentId: 'agent-a' }),
]));
});
it('maps first session binding persistence failure to the stable storage error', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-registry-binding-failure-'));
roots.push(projectPath);