Skip to content
Open
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
27 changes: 27 additions & 0 deletions packages/ui/src/__tests__/conversation-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getConversationCopy } from '../conversation-copy.js';

/**
* A subscription quota window can hand the runtime an hour-scale Retry-After;
* the banner must count down in humanized d/h/m/s units rather than a raw
* five-digit second count that reads as a frozen hang.
*/
test('providerRetryScheduled humanizes hour-scale delays in both locales', () => {
const zh = getConversationCopy('zh').messages.providerRetryScheduled;
const en = getConversationCopy('en').messages.providerRetryScheduled;

// Short delays keep the familiar seconds-only form.
assert.equal(zh(1, 2, 10), '1秒后重试(2/10)');
assert.equal(en(1, 2, 10), 'Retrying in 1s (2/10)');
assert.equal(zh(45, 2, 10), '45秒后重试(2/10)');
assert.equal(en(45, 2, 10), 'Retrying in 45s (2/10)');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] The description says short delays “keep the familiar seconds-only form (45秒后重试)”, but the previous zh copy was ${seconds} 秒后重试 — with a space before . This assertion pins '45秒后重试(2/10)', so the short-delay zh string does change, just subtly.

I think dropping the space is the better copy and I am not asking you to restore it. Only flagging that the description says this path is unchanged when it is not, so a reader diffing screenshots is not left confused.

Separately, and to the test's credit: pinning both locales across second/minute/hour/day scales is exactly the right shape here — it fails if the ladder regresses, rather than restating the implementation.


// Minute- and hour-scale delays spell out the units.
assert.equal(zh(75, 2, 10), '1分 15秒后重试(2/10)');
assert.equal(en(75, 2, 10), 'Retrying in 1m 15s (2/10)');
assert.equal(zh(16_083, 2, 10), '4小时 28分 3秒后重试(2/10)');
assert.equal(en(16_083, 2, 10), 'Retrying in 4h 28m 3s (2/10)');
assert.equal(zh(90_061, 2, 10), '1天 1小时 1分 1秒后重试(2/10)');
assert.equal(en(90_061, 2, 10), 'Retrying in 1d 1h 1m 1s (2/10)');
});
52 changes: 50 additions & 2 deletions packages/ui/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,54 @@ function formatGoalElapsedUnits(elapsedMs: number, units: GoalElapsedUnits): str
return `${Math.floor(hours / 24)}${units.day} ${hours % 24}${units.hour}`;
}

/** Wall-clock units for the provider retry countdown, per locale. */
interface ProviderRetryDelayUnits {
second: string;
minute: string;
hour: string;
day: string;
separator: string;
}

const PROVIDER_RETRY_DELAY_UNITS_ZH: ProviderRetryDelayUnits = {
second: '秒',
minute: '分',
hour: '小时',
day: '天',
separator: ' ',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] separator: ' ' is shared by both locales, so zh renders 4小时 28分 3秒后重试. Chinese typography does not normally space between a number-unit group and the next one — 4小时28分3秒后重试 is the conventional form. Since separator is already per-locale in this interface, zh can just carry '' and en keep ' '; the test expectations would move with it.

Cosmetic, and a native reader should overrule me if they disagree.

};
const PROVIDER_RETRY_DELAY_UNITS_EN: ProviderRetryDelayUnits = {
second: 's',
minute: 'm',
hour: 'h',
day: 'd',
separator: ' ',
};

/**
* Humanized provider retry delay. A subscription quota window can hand the
* runtime an hour-scale Retry-After; a raw five-digit second count reads as a
* frozen hang, so the banner counts down in d/h/m/s units that keep moving
* every second (unlike the goal chip's minute-granularity ladder).
*/
function formatProviderRetryDelay(seconds: number, units: ProviderRetryDelayUnits): string {
let remaining = Math.max(0, Math.floor(seconds));
const parts: string[] = [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This duplicates formatElapsedDuration in packages/cli/src/pi-transcript.ts:1415 exactly — same unit table, same loop, same zero/empty tail, same separator. The only real difference is that the unit strings became a parameter so zh can supply its own.

Simpler equivalent solution, spelled out: hoist this ladder to a package both ui and cli can import, taking the unit strings and separator as the parameter you already designed here, then have formatElapsedDuration and formatProviderRetryDelay both call it. That also makes the [P2] on the TUI strip a one-line change instead of a third copy.

Acknowledging the cost honestly: this is a cross-package move, not a local import, so it is more work than it looks. Not a blocker — but with the ladder now written twice, the third copy is the one that will drift.

for (const [unit, size] of [
[units.day, 86_400],
[units.hour, 3_600],
[units.minute, 60],
] as const) {
const value = Math.floor(remaining / size);
if (value > 0) {
parts.push(`${value}${unit}`);
remaining %= size;
}
}
if (remaining > 0 || parts.length === 0) parts.push(`${remaining}${units.second}`);
return parts.join(units.separator);
}

export interface ConversationCopy {
empty: {
ariaLabel: string;
Expand Down Expand Up @@ -459,7 +507,7 @@ const CONVERSATION_COPY = {
chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`,
},
messages: {
you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `${seconds} 秒后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryReason: { network: '网络中断', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在验证…', safeResume: '安全恢复', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '通过显式技能发送的历史消息暂不支持编辑并重发',
you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatProviderRetryDelay(seconds, PROVIDER_RETRY_DELAY_UNITS_ZH)}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryReason: { network: '网络中断', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在验证…', safeResume: '安全恢复', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '通过显式技能发送的历史消息暂不支持编辑并重发',
userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`,
thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中断)', abortedByStop: '(已中断 · 由停止按钮触发)',
},
Expand Down Expand Up @@ -600,7 +648,7 @@ const CONVERSATION_COPY = {
chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`,
},
messages: {
you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${seconds}s (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryReason: { network: 'Network interrupted', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Safe recovery', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages sent with an explicit skill',
you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatProviderRetryDelay(seconds, PROVIDER_RETRY_DELAY_UNITS_EN)} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryReason: { network: 'Network interrupted', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Safe recovery', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages sent with an explicit skill',
userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`,
thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: '(Interrupted)', abortedByStop: '(Interrupted · Stop button)',
},
Expand Down