Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "读取,或读取和写入",
Expand Down
26 changes: 24 additions & 2 deletions frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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分钟前',
]);
Expand Down Expand Up @@ -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',
]);
Expand All @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/v2/connectors/ToolPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
irreversibleTools,
asksFirst,
outcomeLabel,
reasonLabel,
modeLabel,
entryGrantModes,
entryModeCopy,
Expand Down Expand Up @@ -598,7 +599,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
<ol className="v2-tools__trail">
{trail.calls.map((line) => (
<li key={line.callId} className="v2-tools__trail-line">
<span><span className="v2-tools__outcome" title={outcomeLabel(line.outcome)} aria-hidden="true"><OutcomeGlyph outcome={line.outcome} /></span>{seatLabel(podId, line.agentUserId)} · {line.tool} · {outcomeLabel(line.outcome)}</span>
<span><span className="v2-tools__outcome" title={outcomeLabel(line.outcome)} aria-hidden="true"><OutcomeGlyph outcome={line.outcome} /></span>{seatLabel(podId, line.agentUserId)} · {line.tool} · {outcomeLabel(line.outcome)}{line.reason && (line.outcome === 'refused' || line.outcome === 'failed') ? <span className="v2-tools__trail-reason"> · {reasonLabel(line.reason)}</span> : null}</span>
<span className="v2-tools__trail-when">{localizeRelativeTime(line.at, t, { now, pastEvent: true })}</span>
</li>
))}
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/v2/connectors/useToolConnectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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' }),
Expand Down Expand Up @@ -566,6 +593,7 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa
irreversibleTools,
asksFirst,
outcomeLabel,
reasonLabel,
modeLabel,
entryGrantModes,
entryModeCopy,
Expand Down
Loading