diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b825c537c..2fc82986e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2640,6 +2640,26 @@ "outcomeOk": "ok", "outcomeRefused": "refused", "outcomeSuperseded": "answered", + "reasonNotInAudience": "this seat is outside the grant audience", + "reasonToolNotAllowed": "this tool is not on the allow-list", + "reasonWriteNotAllowed": "writes are not granted", + "reasonGrantRevoked": "the grant was revoked", + "reasonGrantExpired": "the grant expired", + "reasonBudgetExhausted": "the call budget is spent", + "reasonApprovalUnavailable": "no one could be asked to approve it", + "reasonApprovalDeclined": "approval was declined", + "reasonApprovalExpired": "approval expired before the call ran", + "reasonInvalidArgs": "the arguments did not validate", + "reasonConnectionMismatch": "the connection no longer matches the grant", + "reasonReconnect": "the connection needs reconnecting", + "reasonCredentialMissing": "no credential is stored", + "reasonCredentialRejected": "the provider rejected the credential", + "reasonRefreshUnreachable": "the credential could not be refreshed", + "reasonProviderError": "the provider returned an error", + "reasonProviderUnreachable": "the provider could not be reached", + "reasonUpstreamToolError": "the provider tool returned an error", + "reasonBrokerUnavailable": "the broker was unavailable", + "reasonBrokerError": "the broker failed", "owner": "owner", "readInTesting": "read in testing", "readOrWrite": "read, or read and write", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 047e6c131..cba6caeaa 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2643,6 +2643,26 @@ "outcomeOk": "成功", "outcomeRefused": "已拒绝", "outcomeSuperseded": "已回答", + "reasonNotInAudience": "此席位不在授权范围内", + "reasonToolNotAllowed": "此工具不在允许列表中", + "reasonWriteNotAllowed": "未授予写入权限", + "reasonGrantRevoked": "授权已撤销", + "reasonGrantExpired": "授权已过期", + "reasonBudgetExhausted": "调用次数已用完", + "reasonApprovalUnavailable": "无法请求人工批准", + "reasonApprovalDeclined": "审批已被拒绝", + "reasonApprovalExpired": "审批在调用运行前已过期", + "reasonInvalidArgs": "参数校验未通过", + "reasonConnectionMismatch": "连接与授权已不再匹配", + "reasonReconnect": "需要重新连接", + "reasonCredentialMissing": "未保存凭据", + "reasonCredentialRejected": "服务商拒绝了该凭据", + "reasonRefreshUnreachable": "无法刷新凭据", + "reasonProviderError": "服务商返回了错误", + "reasonProviderUnreachable": "无法连接服务商", + "reasonUpstreamToolError": "服务商工具返回了错误", + "reasonBrokerUnavailable": "代理服务不可用", + "reasonBrokerError": "代理服务出错", "owner": "所有者", "readInTesting": "只读功能测试中", "readOrWrite": "读取,或读取和写入", diff --git a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx index 2bddc2c0b..ee23c8a83 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx @@ -315,7 +315,7 @@ describe('Connectors page: tools', () => { const lines = within(aside).getAllByRole('listitem'); expect(lines.map((line) => line.textContent)).toEqual([ 'Scout · github.list_issues · 成功2分钟前', - 'Scout · github.comment_on_issue · 已拒绝5分钟前', + 'Scout · github.comment_on_issue · 已拒绝 · 此席位不在授权范围内5分钟前', 'Scout · github.comment_on_issue · 等待人工确认7分钟前', 'Scout · github.close_issue · 已回答8分钟前', ]); @@ -386,7 +386,7 @@ describe('Connectors page: tools', () => { const lines = within(aside).getAllByRole('listitem'); expect(lines.map((line) => line.textContent)).toEqual([ 'Scout · github.list_issues · ok2m ago', - 'Scout · github.comment_on_issue · refused5m ago', + 'Scout · github.comment_on_issue · refused · this seat is outside the grant audience5m ago', 'Scout · github.comment_on_issue · awaiting a person7m ago', 'Scout · github.close_issue · answered8m ago', ]); @@ -406,6 +406,28 @@ describe('Connectors page: tools', () => { expect(axios.get).toHaveBeenCalledWith('/api/grants/grant_live/calls', expect.objectContaining({ headers: expect.any(Object) })); }); + it('TASK-188: a failed call says why it failed, and a code with no label shows as-is', async () => { + const failed = { + ...trail, + calls: [ + { ...trail.calls[0], callId: 'f1', outcome: 'failed', reason: 'upstream_tool_error', at: iso(-120000) }, + { ...trail.calls[0], callId: 'f2', outcome: 'failed', reason: 'some_new_code', at: iso(-300000) }, + { ...trail.calls[0], callId: 'f3', outcome: 'ok', reason: 'provider_unreachable', at: iso(-420000) }, + { ...trail.calls[0], callId: 'f4', outcome: 'failed', reason: 'constructor', at: iso(-600000) }, + ], + }; + mockTools({ trails: { grant_live: failed } }); + renderPage(); + const { aside } = await openRow('GitHub', 'View GitHub in Launch pod'); + await waitFor(() => expect(within(aside).getAllByRole('listitem')).toHaveLength(4)); + expect(within(aside).getAllByRole('listitem').map((line) => line.textContent)).toEqual([ + 'Scout · github.list_issues · failed · the provider tool returned an error2m ago', + 'Scout · github.list_issues · failed · some_new_code5m ago', + 'Scout · github.list_issues · ok7m ago', + 'Scout · github.list_issues · failed · constructor10m ago', + ]); + }); + it('TASK-050: a human member in the audience is named, not called "an agent"', async () => { mockTools({ grants: [{ ...grantLive, effectiveAudience: ['u1', 'a1'] }], seats: { p1: [scout] } }); renderPage(); diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx index 4c0050ce9..10b22e02a 100644 --- a/frontend/src/v2/connectors/ToolPanels.tsx +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -88,6 +88,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => { irreversibleTools, asksFirst, outcomeLabel, + reasonLabel, modeLabel, entryGrantModes, entryModeCopy, @@ -598,7 +599,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
    {trail.calls.map((line) => (
  1. - {seatLabel(podId, line.agentUserId)} · {line.tool} · {outcomeLabel(line.outcome)} + {seatLabel(podId, line.agentUserId)} · {line.tool} · {outcomeLabel(line.outcome)}{line.reason && (line.outcome === 'refused' || line.outcome === 'failed') ? · {reasonLabel(line.reason)} : null} {localizeRelativeTime(line.at, t, { now, pastEvent: true })}
  2. ))} diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts index e292018a7..c9f3af6f8 100644 --- a/frontend/src/v2/connectors/useToolConnectors.ts +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -199,6 +199,33 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa superseded: t('tools.outcomeSuperseded', { defaultValue: 'answered' }), failed: t('tools.outcomeFailed', { defaultValue: 'failed' }), })[outcome] || outcome; + // Plain words for why a refused or failed call did not run. An unlabelled code shows as-is. + const reasonLabel = (reason: string): string => { + const labels: Record = { + not_in_audience: t('tools.reasonNotInAudience', { defaultValue: 'this seat is outside the grant audience' }), + tool_not_allowed: t('tools.reasonToolNotAllowed', { defaultValue: 'this tool is not on the allow-list' }), + write_mode_not_allowed: t('tools.reasonWriteNotAllowed', { defaultValue: 'writes are not granted' }), + grant_revoked: t('tools.reasonGrantRevoked', { defaultValue: 'the grant was revoked' }), + grant_expired: t('tools.reasonGrantExpired', { defaultValue: 'the grant expired' }), + budget_exhausted: t('tools.reasonBudgetExhausted', { defaultValue: 'the call budget is spent' }), + approval_unavailable: t('tools.reasonApprovalUnavailable', { defaultValue: 'no one could be asked to approve it' }), + approval_declined: t('tools.reasonApprovalDeclined', { defaultValue: 'approval was declined' }), + approval_expired: t('tools.reasonApprovalExpired', { defaultValue: 'approval expired before the call ran' }), + invalid_tool_args: t('tools.reasonInvalidArgs', { defaultValue: 'the arguments did not validate' }), + connection_mismatch: t('tools.reasonConnectionMismatch', { defaultValue: 'the connection no longer matches the grant' }), + reconnect_required: t('tools.reasonReconnect', { defaultValue: 'the connection needs reconnecting' }), + credential_missing: t('tools.reasonCredentialMissing', { defaultValue: 'no credential is stored' }), + credential_rejected: t('tools.reasonCredentialRejected', { defaultValue: 'the provider rejected the credential' }), + refresh_unreachable: t('tools.reasonRefreshUnreachable', { defaultValue: 'the credential could not be refreshed' }), + provider_error: t('tools.reasonProviderError', { defaultValue: 'the provider returned an error' }), + provider_unreachable: t('tools.reasonProviderUnreachable', { defaultValue: 'the provider could not be reached' }), + upstream_tool_error: t('tools.reasonUpstreamToolError', { defaultValue: 'the provider tool returned an error' }), + broker_unavailable: t('tools.reasonBrokerUnavailable', { defaultValue: 'the broker was unavailable' }), + broker_error: t('tools.reasonBrokerError', { defaultValue: 'the broker failed' }), + }; + // Own keys only: a code such as `constructor` or `__proto__` must show as itself, not as an inherited member. + return Object.prototype.hasOwnProperty.call(labels, reason) ? labels[reason] : reason; + }; const modeLabel = (mode: GrantWriteMode): string => ({ read: t('tools.modeRead', { defaultValue: 'read' }), 'write-with-confirm': t('tools.modeWriteConfirm', { defaultValue: 'read and write, ask first' }), @@ -566,6 +593,7 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa irreversibleTools, asksFirst, outcomeLabel, + reasonLabel, modeLabel, entryGrantModes, entryModeCopy,