agent-exec transport 257898d9fdadf655ee20accee6813725/4e997a3ec7a5
governance / governance (pull_request) Successful in 29s
ci / test-and-build (pull_request) Successful in 1m11s

This commit is contained in:
agent-exec-transport
2026-10-20 11:54:59 +00:00
parent a13d041159
commit 9f7f4a164b
2 changed files with 66 additions and 2 deletions
+9 -2
View File
@@ -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({
+57
View File
@@ -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('发送失败:')
})
})