fix: close PI core chat review gaps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 操作当前不属于核心聊天页。
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
235
tests/unit/coding-chat-pressure.test.tsx
Normal file
235
tests/unit/coding-chat-pressure.test.tsx
Normal 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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user