diff --git a/src/store.ts b/src/store.ts index 513d287..12be84b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1145,10 +1145,12 @@ export const store = { set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) }) return 'busy' } - store.toast(`发送失败:${e}`) + const removed = isNotProjectMember(e) + store.toast(removed ? '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看' : `发送失败:${e}`) set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'), - ...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) }) + ...(taskRef ? { taskRef } : {}), + deliveryError: removed ? '无法发送:你已不在该项目中或项目已关闭,内容已保留' : '发送未成功,内容已保留;可修改后重试或移除' }) }) await store.reconcile() return 'rejected' } finally { @@ -1720,6 +1722,11 @@ function isConversationBusy(err: unknown): boolean { return String(err).includes('409:') } +/** 已被移出项目或项目已关闭:后端以 403 `conversation: not-project-member` 拒绝继续发送。 */ +function isNotProjectMember(err: unknown): boolean { + return String(err).includes('not-project-member') +} + function shellPhase() { const liveRunId = state.live?.runId ?? null return deriveShellPhase({ diff --git a/test/send-removed-member.test.ts b/test/send-removed-member.test.ts new file mode 100644 index 0000000..3a1c0d7 --- /dev/null +++ b/test/send-removed-member.test.ts @@ -0,0 +1,57 @@ +// 被移出项目/项目关闭后的发送拒绝:403 `conversation: not-project-member` 走专用文案, +// 其他失败保持通用文案(见 src/store.ts sendMessage 的 catch 分支)。 +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { api } from '../src/api' +import { store } from '../src/store' +import type { Conversation, Message } from '../src/contracts' + +const REMOVED_TOAST = '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看' +const REMOVED_DELIVERY_ERROR = '无法发送:你已不在该项目中或项目已关闭,内容已保留' +const GENERIC_DELIVERY_ERROR = '发送未成功,内容已保留;可修改后重试或移除' + +function detail(id: string) { + return { + conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation, + messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0, + } +} +class FakeEventSource { + onopen = null + onerror = null + onmessage = null + addEventListener() {} + close() {} +} + +beforeEach(async () => { + vi.stubGlobal('EventSource', FakeEventSource) + vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id)) + vi.spyOn(api, 'getPlan').mockResolvedValue(null) + vi.spyOn(api, 'getTodos').mockResolvedValue(null) + vi.spyOn(api, 'listCards').mockResolvedValue([]) + vi.spyOn(api, 'listTasks').mockResolvedValue([]) + vi.spyOn(api, 'listArtifacts').mockResolvedValue([]) + vi.spyOn(api, 'surfaceList').mockResolvedValue([]) + vi.spyOn(api, 'projectSurfaces').mockResolvedValue([]) + vi.spyOn(api, 'listProjectCards').mockResolvedValue([]) + await store.openProject('p') + await store.openConversation('a') +}) +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +describe('sendMessage rejection copy', () => { + it('keeps the message in the outbox with the removed-member copy on 403 not-project-member', async () => { + vi.spyOn(api, 'sendMessage').mockRejectedValue(new Error('403: conversation: not-project-member')) + expect(await store.sendMessage('这条还要继续吗', [], 'kept-id')).toBe('rejected') + expect(store.getState().outbox[0]).toMatchObject({ id: 'kept-id', text: '这条还要继续吗', attachmentIds: [] }) + expect(store.getState().outbox[0].deliveryError).toBe(REMOVED_DELIVERY_ERROR) + expect(store.getState().toast).toBe(REMOVED_TOAST) + }) + + it('keeps the generic copy for any other send failure', async () => { + vi.spyOn(api, 'sendMessage').mockRejectedValue(new Error('500: upstream down')) + expect(await store.sendMessage('这条还要继续吗', [], 'kept-id')).toBe('rejected') + expect(store.getState().outbox[0].deliveryError).toBe(GENERIC_DELIVERY_ERROR) + expect(store.getState().toast).toContain('发送失败:') + }) +}) \ No newline at end of file