




























@@ -107,6 +107,21 @@ function findRequestPayload(source: MockCallSource, method: string, label: strin
107107return requireRecord(call[1], label);
108108}
109109110+function eventPayloads(host: ChatHost, event: string): Array<Record<string, unknown>> {
111+return (host.eventLogBuffer ?? [])
112+.filter((entry): entry is { event: string; payload: Record<string, unknown> } => {
113+if (!entry || typeof entry !== "object") {
114+return false;
115+}
116+const candidate = entry as { event?: unknown; payload?: unknown };
117+return (
118+candidate.event === event &&
119+Boolean(candidate.payload && typeof candidate.payload === "object")
120+);
121+})
122+.map((entry) => entry.payload);
123+}
124+110125function fetchInit(source: MockCallSource, callIndex: number) {
111126return requireRecord(mockArg(source, callIndex, 1, `fetch init ${callIndex}`), "fetch init");
112127}
@@ -1178,6 +1193,36 @@ describe("handleSendChat", () => {
11781193expect(host.chatMessage).toBe("");
11791194});
118011951196+it("records visible send timing phases for a normal chat send", async () => {
1197+const request = vi.fn(async (method: string) => {
1198+if (method === "chat.send") {
1199+return { status: "started" };
1200+}
1201+throw new Error(`Unexpected request: ${method}`);
1202+});
1203+const host = makeHost({
1204+client: { request } as unknown as ChatHost["client"],
1205+chatMessage: "measure first send",
1206+eventLogBuffer: [],
1207+tab: "debug",
1208+});
1209+1210+await handleSendChat(host);
1211+1212+const sendEvents = eventPayloads(host, "control-ui.chat.send");
1213+expect(sendEvents.map((payload) => payload.phase)).toEqual(
1214+expect.arrayContaining(["pending-visible", "request-start", "ack"]),
1215+);
1216+const ack = sendEvents.find((payload) => payload.phase === "ack");
1217+expect(ack).toMatchObject({
1218+ackStatus: "started",
1219+sessionKey: "agent:main",
1220+sendState: "sending",
1221+});
1222+expect(ack?.durationMs).toEqual(expect.any(Number));
1223+expect(ack?.requestDurationMs).toEqual(expect.any(Number));
1224+});
1225+11811226it("waits for an in-flight model picker update before sending chat", async () => {
11821227const switchUpdate = createDeferred<boolean>();
11831228const request = vi.fn(async (method: string) => {
@@ -1196,7 +1241,11 @@ describe("handleSendChat", () => {
11961241await Promise.resolve();
1197124211981243expect(request).not.toHaveBeenCalled();
1199-expect(host.chatMessage).toBe("use the newly selected model");
1244+expect(host.chatMessage).toBe("");
1245+expect(host.chatQueue[0]).toMatchObject({
1246+sendState: "waiting-model",
1247+text: "use the newly selected model",
1248+});
1200124912011250switchUpdate.resolve(true);
12021251await send;
@@ -1288,11 +1337,11 @@ describe("handleSendChat", () => {
12881337expect(attachments[0]?.mimeType).toBe("application/pdf");
12891338expect(attachments[0]?.type).toBe("file");
12901339expect(host.chatMessage).toBe("keep typing with the attachment");
1291-expect(host.chatAttachments).toEqual([attachment]);
1292-expect(getChatAttachmentDataUrl(attachment)).toBe("data:application/pdf;base64,JVBERi0xLjQK");
1340+expect(host.chatAttachments).toStrictEqual([]);
1341+expect(getChatAttachmentDataUrl(attachment)).toBeNull();
12931342});
129413431295-it("preserves draft text when only attachments change during a delayed send", async () => {
1344+it("preserves edited attachments when attachments change during a delayed send", async () => {
12961345const switchUpdate = createDeferred<boolean>();
12971346const request = vi.fn(async (method: string) => {
12981347if (method === "chat.send") {
@@ -1348,7 +1397,7 @@ describe("handleSendChat", () => {
13481397expect(attachments[0]?.fileName).toBe("original.pdf");
13491398expect(attachments[0]?.mimeType).toBe("application/pdf");
13501399expect(attachments[0]?.type).toBe("file");
1351-expect(host.chatMessage).toBe("send this");
1400+expect(host.chatMessage).toBe("");
13521401expect(host.chatAttachments).toEqual([editedAttachment]);
13531402expect(getChatAttachmentDataUrl(originalAttachment)).toBeNull();
13541403expect(getChatAttachmentDataUrl(editedAttachment)).toBe("data:application/pdf;base64,ZWRpdGVk");
@@ -1400,7 +1449,7 @@ describe("handleSendChat", () => {
14001449expect(attachments[0]?.fileName).toBe("original.pdf");
14011450expect(attachments[0]?.mimeType).toBe("application/pdf");
14021451expect(attachments[0]?.type).toBe("file");
1403-expect(host.chatMessage).toBe("send this");
1452+expect(host.chatMessage).toBe("");
14041453expect(host.chatAttachments).toStrictEqual([]);
14051454expect(getChatAttachmentDataUrl(attachment)).toBeNull();
14061455});
@@ -1452,6 +1501,168 @@ describe("handleSendChat", () => {
14521501expect(host.chatMessage).toBe("do not send on rollback");
14531502});
145415031504+it("does not restore canceled attachments onto new draft text after model update failure", async () => {
1505+const switchUpdate = createDeferred<boolean>();
1506+const request = vi.fn(async (method: string) => {
1507+throw new Error(`Unexpected request: ${method}`);
1508+});
1509+const file = new File(["private"], "private.txt", { type: "text/plain" });
1510+const attachment = registerChatAttachmentPayload({
1511+attachment: {
1512+id: "private-att",
1513+mimeType: "text/plain",
1514+fileName: "private.txt",
1515+sizeBytes: file.size,
1516+},
1517+dataUrl: "data:text/plain;base64,cHJpdmF0ZQ==",
1518+ file,
1519+});
1520+const host = makeHost({
1521+client: { request } as unknown as ChatHost["client"],
1522+chatAttachments: [attachment],
1523+chatMessage: "send this attachment",
1524+chatModelSwitchPromises: { "agent:main": switchUpdate.promise },
1525+});
1526+1527+const send = handleSendChat(host);
1528+await Promise.resolve();
1529+host.chatMessage = "new unrelated draft";
1530+1531+switchUpdate.resolve(false);
1532+await send;
1533+1534+expect(request).not.toHaveBeenCalled();
1535+expect(host.chatMessage).toBe("new unrelated draft");
1536+expect(host.chatAttachments).toStrictEqual([]);
1537+expect(getChatAttachmentDataUrl(attachment)).toBeNull();
1538+});
1539+1540+it("does not restore a manually removed model-wait send after model update failure", async () => {
1541+const switchUpdate = createDeferred<boolean>();
1542+const request = vi.fn(async (method: string) => {
1543+throw new Error(`Unexpected request: ${method}`);
1544+});
1545+const host = makeHost({
1546+client: { request } as unknown as ChatHost["client"],
1547+chatMessage: "remove this pending send",
1548+chatModelSwitchPromises: { "agent:main": switchUpdate.promise },
1549+});
1550+1551+const send = handleSendChat(host);
1552+await Promise.resolve();
1553+const queuedId = host.chatQueue[0]?.id;
1554+expect(queuedId).toEqual(expect.any(String));
1555+removeQueuedMessage(host, queuedId);
1556+1557+switchUpdate.resolve(false);
1558+await send;
1559+1560+expect(request).not.toHaveBeenCalled();
1561+expect(host.chatMessage).toBe("");
1562+expect(host.chatQueue).toStrictEqual([]);
1563+});
1564+1565+it("keeps resolved model-wait sends queued under the submitted session after switching", async () => {
1566+const switchUpdate = createDeferred<boolean>();
1567+const request = vi.fn(async (method: string) => {
1568+throw new Error(`Unexpected request: ${method}`);
1569+});
1570+const host = makeHost({
1571+client: { request } as unknown as ChatHost["client"],
1572+chatMessage: "send from session a",
1573+chatModelSwitchPromises: { "agent:main": switchUpdate.promise },
1574+sessionKey: "agent:main",
1575+});
1576+1577+const send = handleSendChat(host);
1578+await Promise.resolve();
1579+expect(host.chatMessage).toBe("");
1580+expect(host.chatQueue[0]?.text).toBe("send from session a");
1581+1582+host.chatQueueBySession = { "agent:main": [...host.chatQueue] };
1583+host.chatQueue = [];
1584+host.sessionKey = "agent:other";
1585+host.chatMessage = "session b draft";
1586+switchUpdate.resolve(true);
1587+await send;
1588+1589+expect(request).not.toHaveBeenCalled();
1590+expect(host.chatMessage).toBe("session b draft");
1591+expect(host.chatQueue).toStrictEqual([]);
1592+expect(host.chatQueueBySession?.["agent:main"]?.[0]).toMatchObject({
1593+sendState: undefined,
1594+text: "send from session a",
1595+});
1596+});
1597+1598+it("keeps failed model-wait sends retryable under the submitted session after switching", async () => {
1599+const switchUpdate = createDeferred<boolean>();
1600+const request = vi.fn(async (method: string) => {
1601+throw new Error(`Unexpected request: ${method}`);
1602+});
1603+const host = makeHost({
1604+client: { request } as unknown as ChatHost["client"],
1605+chatMessage: "send from session a",
1606+chatModelSwitchPromises: { "agent:main": switchUpdate.promise },
1607+sessionKey: "agent:main",
1608+});
1609+1610+const send = handleSendChat(host);
1611+await Promise.resolve();
1612+host.chatQueueBySession = { "agent:main": [...host.chatQueue] };
1613+host.chatQueue = [];
1614+host.sessionKey = "agent:other";
1615+host.chatMessage = "";
1616+1617+switchUpdate.resolve(false);
1618+await send;
1619+1620+expect(request).not.toHaveBeenCalled();
1621+expect(host.chatMessage).toBe("");
1622+expect(host.chatQueue).toStrictEqual([]);
1623+expect(host.chatQueueBySession?.["agent:main"]?.[0]).toMatchObject({
1624+sendError: "Model selection was interrupted. Review and retry when ready.",
1625+sendState: "failed",
1626+text: "send from session a",
1627+});
1628+});
1629+1630+it("does not flush model-wait sends before the model picker update finishes", async () => {
1631+const switchUpdate = createDeferred<boolean>();
1632+const request = vi.fn(async (method: string) => {
1633+if (method === "chat.send") {
1634+return { status: "started" };
1635+}
1636+throw new Error(`Unexpected request: ${method}`);
1637+});
1638+const host = makeHost({
1639+client: { request } as unknown as ChatHost["client"],
1640+chatMessage: "wait for selected model",
1641+chatModelSwitchPromises: { "agent:main": switchUpdate.promise },
1642+});
1643+1644+const send = handleSendChat(host);
1645+await Promise.resolve();
1646+expect(host.chatQueue[0]).toMatchObject({
1647+sendState: "waiting-model",
1648+text: "wait for selected model",
1649+});
1650+1651+await retryReconnectableQueuedChatSends(host);
1652+expect(request).not.toHaveBeenCalled();
1653+expect(host.chatQueue[0]?.sendState).toBe("waiting-model");
1654+1655+switchUpdate.resolve(true);
1656+await send;
1657+1658+const payload = findRequestPayload(
1659+request as unknown as MockCallSource,
1660+"chat.send",
1661+"chat send payload",
1662+);
1663+expect(payload.message).toBe("wait for selected model");
1664+});
1665+14551666it("keeps slash-command model changes in sync with the chat header cache", async () => {
14561667vi.stubGlobal(
14571668"fetch",
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。