feat(team): improve review change evidence flow
This commit is contained in:
parent
5d3ec8a8bd
commit
bceef9dec5
41 changed files with 3721 additions and 250 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
Most team issues fall into one of five buckets: runtime setup, launch confirmation, task parsing, provider limits, and review state gaps.
|
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, and provider limits.
|
||||||
|
|
||||||
## Team does not launch
|
## Team does not launch
|
||||||
|
|
||||||
|
|
@ -12,6 +12,10 @@ Check each item in order:
|
||||||
4. **Project path** — the project directory exists and is readable
|
4. **Project path** — the project directory exists and is readable
|
||||||
5. **Network / VPN** — some providers drop traffic when a VPN is active
|
5. **Network / VPN** — some providers drop traffic when a VPN is active
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
Run the runtime binary in a terminal to verify `PATH` and auth. Example: `claude --version` or `opencode --version`.
|
||||||
|
:::
|
||||||
|
|
||||||
### OpenCode: registered but bootstrap unconfirmed
|
### OpenCode: registered but bootstrap unconfirmed
|
||||||
|
|
||||||
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
|
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
|
||||||
|
|
@ -83,6 +87,12 @@ If the CLI is authenticated in one terminal but the app says it is not, verify t
|
||||||
- Double-check the provider name in `config.json` matches the provider prefix in the model string
|
- Double-check the provider name in `config.json` matches the provider prefix in the model string
|
||||||
- Ensure the key is not expired or revoked in the provider dashboard
|
- Ensure the key is not expired or revoked in the provider dashboard
|
||||||
|
|
||||||
|
### Auth diagnostic log
|
||||||
|
|
||||||
|
Each call to `CliInstallerService.getStatus()` appends one line to `claude-cli-auth-diag.ndjson` in the Electron log folder (usually `~/Library/Logs/<product-name>/` on macOS). If the file exceeds **512 KiB**, it is truncated to empty before the next write.
|
||||||
|
|
||||||
|
Check this file if you see "Not logged in" or auth errors in the packaged app.
|
||||||
|
|
||||||
## Lane bootstrap stuck
|
## Lane bootstrap stuck
|
||||||
|
|
||||||
For OpenCode secondary lanes:
|
For OpenCode secondary lanes:
|
||||||
|
|
@ -95,6 +105,41 @@ For OpenCode secondary lanes:
|
||||||
|
|
||||||
If the bridge says bootstrap succeeded but `manifest.json` shows `entries: []`, the issue is **evidence commit**, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
|
If the bridge says bootstrap succeeded but `manifest.json` shows `entries: []`, the issue is **evidence commit**, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
|
||||||
|
|
||||||
|
## Common member states
|
||||||
|
|
||||||
|
| State | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `confirmed_alive` + `bootstrapConfirmed` | Healthy and ready |
|
||||||
|
| `registered` / `runtime_pending_bootstrap` | Process or lane exists, but bootstrap proof has not been committed yet |
|
||||||
|
| `failed_to_start` + `runtime_process` | Process exists, but launch gate failed. Check diagnostics |
|
||||||
|
| `failed_to_start` + `stale_metadata` | Saved pid/session is stale or dead |
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
`member_briefing` by itself is NOT runtime evidence. For OpenCode, authoritative proof is committed runtime evidence such as `opencode-sessions.json` and the manifest entry.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Runtime debug mode
|
||||||
|
|
||||||
|
For local debugging, you can force teammates to run in tmux panes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch from a terminal
|
||||||
|
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||||
|
|
||||||
|
# Or add to custom CLI args
|
||||||
|
--teammate-mode tmux
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this to inspect interactive CLI behavior. Do not consider this fully equivalent to the process backend.
|
||||||
|
|
||||||
|
## Safe cleanup
|
||||||
|
|
||||||
|
When cleaning up stale processes:
|
||||||
|
|
||||||
|
1. Identify the pid and confirm it belongs to the current team / lane.
|
||||||
|
2. Stop only processes explicitly belonging to a smoke test or the launch you are debugging.
|
||||||
|
3. **Do not kill** all OpenCode or shared host processes as a shortcut.
|
||||||
|
|
||||||
## When to collect evidence
|
## When to collect evidence
|
||||||
|
|
||||||
Before asking for help, collect:
|
Before asking for help, collect:
|
||||||
|
|
@ -107,3 +152,7 @@ Before asking for help, collect:
|
||||||
- Exact time window when the issue occurred
|
- Exact time window when the issue occurred
|
||||||
|
|
||||||
This data is usually enough to debug launch and task lifecycle issues.
|
This data is usually enough to debug launch and task lifecycle issues.
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
If the issue persists, open the team's persisted files under `~/.claude/teams/<teamName>/` and correlate UI diagnostics with the live process state before changing code.
|
||||||
|
:::
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,59 @@
|
||||||
# Диагностика
|
# Диагностика
|
||||||
|
|
||||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits.
|
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing и provider limits.
|
||||||
|
|
||||||
## Команда не запускается
|
## Команда не запускается
|
||||||
|
|
||||||
Проверьте:
|
Проверьте последовательно:
|
||||||
|
|
||||||
- Выбранный runtime установлен или авторизован
|
1. **Runtime установлен** — выбранный CLI (`claude`, `codex`, `opencode`) установлен
|
||||||
- Runtime доступен в environment `PATH`
|
2. **Доступен в PATH** — бинарник доступен в переменной окружения `PATH`
|
||||||
- У провайдера есть доступ к нужной модели
|
3. **Доступ к модели** — у провайдера есть доступ к запрошенной модели (особенно для OpenCode, важны точные имена провайдера и модели)
|
||||||
- Project path существует и читается
|
4. **Путь к проекту** — директория проекта существует и доступна для чтения
|
||||||
|
5. **Сеть / VPN** — некоторые провайдеры блокируют трафик при активном VPN
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`.
|
Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### OpenCode: bootstrap не подтверждён
|
### OpenCode: registered, но bootstrap не подтверждён
|
||||||
|
|
||||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён:
|
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала inspect artifacts, прежде чем менять team prompts.
|
||||||
|
|
||||||
1. Откройте launch logs в UI.
|
Посмотрите на последний artifact неудачного запуска:
|
||||||
2. Проверьте `~/.claude/teams/<team>/launch-state.json` — состояние member.
|
|
||||||
3. Посмотрите `~/.claude/teams/<team>/.opencode-runtime/lanes/<lane-id>/manifest.json` на наличие evidence.
|
|
||||||
4. Не меняйте team prompts, пока не убедитесь, что lane стартовал, но не смог закоммитить evidence.
|
|
||||||
|
|
||||||
::: warning
|
```bash
|
||||||
Отсутствие OpenCode inbox во время primary launch — норма. Secondary lanes стартуют после готовности primary filesystem. Не считайте primary hang багом OpenCode, пока UI явно не показывает, что `Y` членов ждёт и `Y` некорректно включает OpenCode lanes.
|
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Манифест внутри включает:
|
||||||
|
|
||||||
|
- `classification` — почему запуск считался неудачным
|
||||||
|
- `bootstrapTransportBreadcrumb` — использованный путь доставки
|
||||||
|
- Статусы старта участников
|
||||||
|
- Редактированные логи и трейсы
|
||||||
|
|
||||||
|
Также проверьте lane manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json
|
||||||
|
jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane>/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip Не гадайте по UI
|
||||||
|
Всегда сопоставляйте UI-диагностику с сохранёнными файлами (`launch-state.json`, `bootstrap-journal.jsonl`) и runtime-специфичными доказательствами.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Не видны ответы агента
|
## Не видны ответы агента
|
||||||
|
|
||||||
Откройте task logs и teammate messages. Пропавшие replies часто связаны с:
|
Откройте task logs и teammate messages. Пропавшие replies часто связаны с:
|
||||||
|
|
||||||
- Runtime delivery gaps
|
- **Runtime delivery retry** — агент мог ответить, но сообщение не доставлено в приложение. Проверьте delivery ledger.
|
||||||
- Parsing или task filtering issues
|
- **Parsing или filtering** — вывод агента не содержал ожидаемых маркеров или task references.
|
||||||
- Агент всё ещё обрабатывает (большие задачи могут занимать минуты)
|
- **Task attribution** — работа выполнялась в рамках сессии, но не была привязана к задаче, потому что в выводе отсутствовал корректный task id.
|
||||||
|
|
||||||
|
::: warning Не считайте молчание игнорированием
|
||||||
Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
|
Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
|
||||||
|
|
||||||
::: tip
|
|
||||||
Для OpenCode teammates проверьте, что вызван `agent-teams_message_send` с правильными `from`, `to` и `taskRefs`. Ответы OpenCode должны отправляться через MCP tools, а не обычным текстом.
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Changes не связаны с tasks
|
## Changes не связаны с tasks
|
||||||
|
|
@ -50,9 +64,46 @@
|
||||||
- Убедитесь, что агент вызвал `task_add_comment` перед правками.
|
- Убедитесь, что агент вызвал `task_add_comment` перед правками.
|
||||||
- Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы.
|
- Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы.
|
||||||
|
|
||||||
|
Для OpenCode teammates авторитетным доказательством принадлежности сессии к задаче служат `opencode-sessions.json` и запись в lane manifest, а не только UI message stream.
|
||||||
|
|
||||||
## Rate limits
|
## Rate limits
|
||||||
|
|
||||||
Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
Если провайдер сообщает известное время сброса (reset time), Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
||||||
|
|
||||||
|
| Поведение провайдера | Рекомендуемое действие |
|
||||||
|
| --- | --- |
|
||||||
|
| Отображается известное reset time | Дождитесь cooldown и продолжите |
|
||||||
|
| Reset time не показан | Смените провайдера или runtime path |
|
||||||
|
| Повторяющиеся 429 | Снизьте concurrency или используйте другую model lane |
|
||||||
|
|
||||||
|
## Проблемы авторизации CLI
|
||||||
|
|
||||||
|
### `claude login` не сохраняется
|
||||||
|
|
||||||
|
Если CLI авторизован в одном терминале, но приложение говорит, что нет — проверьте, что auth сохранён по ожидаемому пути конфигурации, и что процесс приложения видит тот же `$HOME`.
|
||||||
|
|
||||||
|
### OpenCode: ключ провайдера отклонён
|
||||||
|
|
||||||
|
- Убедитесь, что имя провайдера в `config.json` совпадает с префиксом провайдера в строке модели
|
||||||
|
- Проверьте, что ключ не просрочен и не отозван в dashboard провайдера
|
||||||
|
|
||||||
|
### Диагностический лог авторизации
|
||||||
|
|
||||||
|
Каждый вызов `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs/<product-name>/` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
|
||||||
|
|
||||||
|
Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
|
||||||
|
|
||||||
|
## Lane bootstrap stuck
|
||||||
|
|
||||||
|
Для OpenCode secondary lanes:
|
||||||
|
|
||||||
|
- Отсутствие `inboxes/<member>.json` автоматически не является багом. OpenCode lanes не обязаны быть созданы через primary inbox перед стартом.
|
||||||
|
- Если UI показывает, что команда всё ещё запускается, в то время как primary participants уже работоспособны, ожидание «all teammates joined» связано с secondary lanes.
|
||||||
|
- Если зависает `Prepared communication channels for X/Y members`, проверьте, не включает ли `Y` некорректно secondary OpenCode members.
|
||||||
|
|
||||||
|
### Lane manifest empty entries
|
||||||
|
|
||||||
|
Если bridge сообщает, что bootstrap успешен, но `manifest.json` показывает `entries: []`, проблема в **evidence commit**, а не в поведении модели. Участник не должен считаться deliverable, пока `opencode-sessions.json` и запись в manifest не существуют.
|
||||||
|
|
||||||
## Распространённые состояния member
|
## Распространённые состояния member
|
||||||
|
|
||||||
|
|
@ -81,17 +132,11 @@ CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||||
|
|
||||||
Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend.
|
Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend.
|
||||||
|
|
||||||
## CLI auth diagnostic
|
|
||||||
|
|
||||||
Каждый запуск `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs/<product-name>/` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
|
|
||||||
|
|
||||||
Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
|
|
||||||
|
|
||||||
## Безопасная очистка
|
## Безопасная очистка
|
||||||
|
|
||||||
При очистке stale processes:
|
При очистке stale processes:
|
||||||
|
|
||||||
1. Определите pid и убедитесь, что он принадлежит текущей команде/lane.
|
1. Определите pid и убедитесь, что он принадлежит текущей команде / lane.
|
||||||
2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch.
|
2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch.
|
||||||
3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut.
|
3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut.
|
||||||
|
|
||||||
|
|
@ -99,11 +144,11 @@ CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||||
|
|
||||||
Соберите:
|
Соберите:
|
||||||
|
|
||||||
- task id
|
- task id (short или full)
|
||||||
- team name
|
- team name
|
||||||
- runtime path
|
- runtime path (`claude`, `codex`, или `opencode`)
|
||||||
- launch log excerpt
|
- launch log excerpt (из `latest.json` или `bootstrap-journal.jsonl`)
|
||||||
- provider/model
|
- provider / model
|
||||||
- точный time window
|
- точный time window
|
||||||
|
|
||||||
Этого обычно хватает для диагностики launch и task lifecycle issues.
|
Этого обычно хватает для диагностики launch и task lifecycle issues.
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from '../core/domain';
|
export { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from '../core/domain';
|
||||||
|
export { resolveAgentAttachmentCapability, type AgentAttachmentCapability } from '../core/domain';
|
||||||
export * from './optimizeImageForAgent';
|
export * from './optimizeImageForAgent';
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,8 @@ export class MemberWorkSyncNudgeDispatcher {
|
||||||
teamName: item.teamName,
|
teamName: item.teamName,
|
||||||
memberName: item.memberName,
|
memberName: item.memberName,
|
||||||
nowIso,
|
nowIso,
|
||||||
|
workSyncIntent: item.payload.workSyncIntent,
|
||||||
|
taskRefs: item.payload.taskRefs,
|
||||||
});
|
});
|
||||||
if (busy?.busy) {
|
if (busy?.busy) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,8 @@ export interface MemberWorkSyncBusySignalPort {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
nowIso: string;
|
nowIso: string;
|
||||||
|
workSyncIntent?: MemberWorkSyncOutboxItem['payload']['workSyncIntent'];
|
||||||
|
taskRefs?: MemberWorkSyncOutboxItem['payload']['taskRefs'];
|
||||||
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
|
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ import {
|
||||||
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { isReviewPickupEscalationMessage } from '@shared/utils/teamAutomationMessages';
|
||||||
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
@ -568,6 +569,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
||||||
// Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs,
|
// Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs,
|
||||||
// not user-visible conversation messages.
|
// not user-visible conversation messages.
|
||||||
if (isTeamInternalControlMessageEnvelope(msg)) continue;
|
if (isTeamInternalControlMessageEnvelope(msg)) continue;
|
||||||
|
// Skip internal review-pickup escalations. They are control-plane signals to the lead runtime,
|
||||||
|
// not user-facing inbox messages.
|
||||||
|
if (isReviewPickupEscalationMessage(msg)) continue;
|
||||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||||
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3059,7 +3059,9 @@ async function handleSendMessage(
|
||||||
queuedBehindMessageId: delivery.queuedBehindMessageId,
|
queuedBehindMessageId: delivery.queuedBehindMessageId,
|
||||||
reason: delivery.reason,
|
reason: delivery.reason,
|
||||||
diagnostics: delivery.diagnostics,
|
diagnostics: delivery.diagnostics,
|
||||||
userVisibleImpact: provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact(delivery),
|
userVisibleImpact:
|
||||||
|
delivery.userVisibleImpact ??
|
||||||
|
provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact(delivery),
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
!delivery.delivered &&
|
!delivery.delivered &&
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { appendOpenCodeTaskChangeDiag } from '@main/utils/openCodeTaskChangeDiag
|
||||||
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
|
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
|
||||||
|
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||||
import {
|
import {
|
||||||
getTaskChangeStateBucket,
|
getTaskChangeStateBucket,
|
||||||
isTaskChangeSummaryCacheable,
|
isTaskChangeSummaryCacheable,
|
||||||
|
|
@ -1542,8 +1543,12 @@ export class ChangeExtractorService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reviewability = classifyTaskChangeReviewability(result);
|
||||||
const resolvedPresence = resolveTaskChangePresenceFromResult(result);
|
const resolvedPresence = resolveTaskChangePresenceFromResult(result);
|
||||||
if (!resolvedPresence) {
|
if (!resolvedPresence) {
|
||||||
|
if (reviewability.reviewability === 'diagnostic_only') {
|
||||||
|
await this.taskChangePresenceRepository.deleteEntry?.(teamName, taskId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
|
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
|
||||||
|
import {
|
||||||
|
createTaskChangeDiagnosticFromWarning,
|
||||||
|
mergeTaskChangeReviewDiagnostics,
|
||||||
|
} from '@shared/utils/taskChangeReviewability';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { diffLines } from 'diff';
|
import { diffLines } from 'diff';
|
||||||
import { open, readFile } from 'fs/promises';
|
import { open, readFile } from 'fs/promises';
|
||||||
|
|
@ -14,6 +18,7 @@ import type {
|
||||||
SnippetDiff,
|
SnippetDiff,
|
||||||
TaskChangeJournalStamp,
|
TaskChangeJournalStamp,
|
||||||
TaskChangeProvenance,
|
TaskChangeProvenance,
|
||||||
|
TaskChangeReviewDiagnostic,
|
||||||
TaskChangeScope,
|
TaskChangeScope,
|
||||||
TaskChangeSetV2,
|
TaskChangeSetV2,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -847,6 +852,7 @@ export class TaskChangeLedgerReader {
|
||||||
provenance: TaskChangeProvenance;
|
provenance: TaskChangeProvenance;
|
||||||
extraWarnings?: string[];
|
extraWarnings?: string[];
|
||||||
}): TaskChangeSetV2 {
|
}): TaskChangeSetV2 {
|
||||||
|
const warnings = [...params.bundle.warnings, ...(params.extraWarnings ?? [])];
|
||||||
return {
|
return {
|
||||||
teamName: params.teamName,
|
teamName: params.teamName,
|
||||||
taskId: params.taskId,
|
taskId: params.taskId,
|
||||||
|
|
@ -857,7 +863,14 @@ export class TaskChangeLedgerReader {
|
||||||
confidence: params.bundle.confidence,
|
confidence: params.bundle.confidence,
|
||||||
computedAt: params.bundle.generatedAt,
|
computedAt: params.bundle.generatedAt,
|
||||||
scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files),
|
scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files),
|
||||||
warnings: [...params.bundle.warnings, ...(params.extraWarnings ?? [])],
|
warnings,
|
||||||
|
reviewDiagnostics: this.withSummaryStateDiagnostics(
|
||||||
|
this.buildReviewDiagnosticsFromWarnings(warnings, 'ledger'),
|
||||||
|
{
|
||||||
|
integrity: params.bundle.integrity,
|
||||||
|
diffStatCompleteness: params.bundle.diffStatCompleteness,
|
||||||
|
}
|
||||||
|
),
|
||||||
diffStatCompleteness: params.bundle.diffStatCompleteness,
|
diffStatCompleteness: params.bundle.diffStatCompleteness,
|
||||||
provenance: params.provenance,
|
provenance: params.provenance,
|
||||||
};
|
};
|
||||||
|
|
@ -878,6 +891,13 @@ export class TaskChangeLedgerReader {
|
||||||
const warnings = this.collectWarnings(projectedEvents, params.journal.notices, {
|
const warnings = this.collectWarnings(projectedEvents, params.journal.notices, {
|
||||||
recovered: params.journal.recovered,
|
recovered: params.journal.recovered,
|
||||||
});
|
});
|
||||||
|
const reviewDiagnostics = this.collectReviewDiagnostics(
|
||||||
|
projectedEvents,
|
||||||
|
params.journal.notices,
|
||||||
|
{
|
||||||
|
recovered: params.journal.recovered,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let files: FileChangeSummary[];
|
let files: FileChangeSummary[];
|
||||||
let totalLinesAdded: number;
|
let totalLinesAdded: number;
|
||||||
|
|
@ -930,8 +950,12 @@ export class TaskChangeLedgerReader {
|
||||||
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
||||||
? 'complete'
|
? 'complete'
|
||||||
: 'partial';
|
: 'partial';
|
||||||
warnings.push(
|
const fallbackWarning =
|
||||||
'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'
|
'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.';
|
||||||
|
warnings.push(fallbackWarning);
|
||||||
|
this.addReviewDiagnostic(
|
||||||
|
reviewDiagnostics,
|
||||||
|
createTaskChangeDiagnosticFromWarning(fallbackWarning, 'summary')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -946,6 +970,10 @@ export class TaskChangeLedgerReader {
|
||||||
computedAt: params.bundle?.generatedAt ?? new Date().toISOString(),
|
computedAt: params.bundle?.generatedAt ?? new Date().toISOString(),
|
||||||
scope,
|
scope,
|
||||||
warnings,
|
warnings,
|
||||||
|
reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, {
|
||||||
|
integrity: params.bundle?.integrity ?? params.provenance.integrity,
|
||||||
|
diffStatCompleteness,
|
||||||
|
}),
|
||||||
...(diffStatCompleteness ? { diffStatCompleteness } : {}),
|
...(diffStatCompleteness ? { diffStatCompleteness } : {}),
|
||||||
provenance: params.provenance,
|
provenance: params.provenance,
|
||||||
};
|
};
|
||||||
|
|
@ -967,6 +995,27 @@ export class TaskChangeLedgerReader {
|
||||||
const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null));
|
const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null));
|
||||||
const grouped = this.groupSnippets(snippets);
|
const grouped = this.groupSnippets(snippets);
|
||||||
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
|
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
|
||||||
|
const fallbackWarning = 'Task change summary fell back to journal reconstruction.';
|
||||||
|
const warnings = [
|
||||||
|
...this.collectWarnings(projectedEvents, params.journal.notices, {
|
||||||
|
recovered: params.journal.recovered,
|
||||||
|
}),
|
||||||
|
fallbackWarning,
|
||||||
|
];
|
||||||
|
const reviewDiagnostics = this.collectReviewDiagnostics(
|
||||||
|
projectedEvents,
|
||||||
|
params.journal.notices,
|
||||||
|
{
|
||||||
|
recovered: params.journal.recovered,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.addReviewDiagnostic(
|
||||||
|
reviewDiagnostics,
|
||||||
|
createTaskChangeDiagnosticFromWarning(fallbackWarning, 'summary')
|
||||||
|
);
|
||||||
|
const diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
||||||
|
? 'complete'
|
||||||
|
: 'partial';
|
||||||
return {
|
return {
|
||||||
teamName: params.teamName,
|
teamName: params.teamName,
|
||||||
taskId: params.taskId,
|
taskId: params.taskId,
|
||||||
|
|
@ -986,15 +1035,12 @@ export class TaskChangeLedgerReader {
|
||||||
projectedEvents,
|
projectedEvents,
|
||||||
params.journal.notices
|
params.journal.notices
|
||||||
),
|
),
|
||||||
warnings: [
|
warnings,
|
||||||
...this.collectWarnings(projectedEvents, params.journal.notices, {
|
reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, {
|
||||||
recovered: params.journal.recovered,
|
integrity: provenance.integrity,
|
||||||
}),
|
diffStatCompleteness,
|
||||||
'Task change summary fell back to journal reconstruction.',
|
}),
|
||||||
],
|
diffStatCompleteness,
|
||||||
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
|
|
||||||
? 'complete'
|
|
||||||
: 'partial',
|
|
||||||
provenance,
|
provenance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1017,6 +1063,10 @@ export class TaskChangeLedgerReader {
|
||||||
'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.'
|
'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.'
|
||||||
);
|
);
|
||||||
for (const notice of params.bundle.notices ?? []) warnings.add(notice.message);
|
for (const notice of params.bundle.notices ?? []) warnings.add(notice.message);
|
||||||
|
const warningList = [...warnings];
|
||||||
|
const diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
||||||
|
? 'complete'
|
||||||
|
: 'partial';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName: params.teamName,
|
teamName: params.teamName,
|
||||||
|
|
@ -1035,10 +1085,12 @@ export class TaskChangeLedgerReader {
|
||||||
params.bundle.events,
|
params.bundle.events,
|
||||||
params.bundle.notices ?? []
|
params.bundle.notices ?? []
|
||||||
),
|
),
|
||||||
warnings: [...warnings],
|
warnings: warningList,
|
||||||
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
|
reviewDiagnostics: this.withSummaryStateDiagnostics(
|
||||||
? 'complete'
|
this.buildReviewDiagnosticsFromWarnings(warningList, 'ledger'),
|
||||||
: 'partial',
|
{ diffStatCompleteness }
|
||||||
|
),
|
||||||
|
diffStatCompleteness,
|
||||||
provenance: {
|
provenance: {
|
||||||
sourceKind: 'ledger',
|
sourceKind: 'ledger',
|
||||||
sourceFingerprint: this.hashFingerprintPayload({
|
sourceFingerprint: this.hashFingerprintPayload({
|
||||||
|
|
@ -1478,6 +1530,150 @@ export class TaskChangeLedgerReader {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addReviewDiagnostic(
|
||||||
|
diagnostics: TaskChangeReviewDiagnostic[],
|
||||||
|
diagnostic: TaskChangeReviewDiagnostic
|
||||||
|
): void {
|
||||||
|
const existingIndex = diagnostics.findIndex(
|
||||||
|
(existing) => existing.code === diagnostic.code && existing.message === diagnostic.message
|
||||||
|
);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = diagnostics[existingIndex];
|
||||||
|
if (existing) {
|
||||||
|
diagnostics[existingIndex] = mergeTaskChangeReviewDiagnostics(existing, diagnostic);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
diagnostics.push(diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildReviewDiagnosticsFromWarnings(
|
||||||
|
warnings: string[],
|
||||||
|
source: TaskChangeReviewDiagnostic['source']
|
||||||
|
): TaskChangeReviewDiagnostic[] {
|
||||||
|
const diagnostics: TaskChangeReviewDiagnostic[] = [];
|
||||||
|
for (const warning of warnings) {
|
||||||
|
this.addReviewDiagnostic(diagnostics, createTaskChangeDiagnosticFromWarning(warning, source));
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
private diagnosticFromNotice(notice: LedgerNotice): TaskChangeReviewDiagnostic {
|
||||||
|
if (notice.code === 'multi-scope-skipped') {
|
||||||
|
return {
|
||||||
|
code: 'multi_scope_no_safe_diff',
|
||||||
|
severity: 'info',
|
||||||
|
reviewBlocking: false,
|
||||||
|
message:
|
||||||
|
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
|
||||||
|
source: 'ledger',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (notice.code === 'journal-recovered') {
|
||||||
|
return {
|
||||||
|
code: 'ledger_integrity_recovered',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||||
|
source: 'ledger',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (notice.code === 'writer-lock-stolen') {
|
||||||
|
return {
|
||||||
|
code: 'unsafe_or_untrusted_evidence',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger writer lock changed while evidence was being recorded.',
|
||||||
|
source: 'ledger',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return createTaskChangeDiagnosticFromWarning(notice.message, 'ledger');
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectReviewDiagnostics(
|
||||||
|
events: LedgerEvent[],
|
||||||
|
notices: LedgerNotice[],
|
||||||
|
options: { recovered: boolean }
|
||||||
|
): TaskChangeReviewDiagnostic[] {
|
||||||
|
const diagnostics: TaskChangeReviewDiagnostic[] = [];
|
||||||
|
for (const notice of notices) {
|
||||||
|
this.addReviewDiagnostic(diagnostics, this.diagnosticFromNotice(notice));
|
||||||
|
}
|
||||||
|
for (const event of events) {
|
||||||
|
for (const warning of event.warnings ?? []) {
|
||||||
|
this.addReviewDiagnostic(
|
||||||
|
diagnostics,
|
||||||
|
createTaskChangeDiagnosticFromWarning(warning, 'ledger')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.toolStatus === 'failed') {
|
||||||
|
this.addReviewDiagnostic(diagnostics, {
|
||||||
|
code: 'tool_failed_after_edit',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: `Tool ${event.toolUseId} failed after changing files.`,
|
||||||
|
source: 'ledger',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.toolStatus === 'killed') {
|
||||||
|
this.addReviewDiagnostic(diagnostics, {
|
||||||
|
code: 'tool_killed_after_edit',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: `Background tool ${event.toolUseId} was killed after changing files.`,
|
||||||
|
source: 'ledger',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.recovered) {
|
||||||
|
this.addReviewDiagnostic(diagnostics, {
|
||||||
|
code: 'ledger_integrity_recovered',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||||
|
source: 'ledger',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
private withSummaryStateDiagnostics(
|
||||||
|
diagnostics: TaskChangeReviewDiagnostic[],
|
||||||
|
state: {
|
||||||
|
integrity?: 'ok' | 'recovered' | 'partial';
|
||||||
|
diffStatCompleteness?: 'complete' | 'partial';
|
||||||
|
}
|
||||||
|
): TaskChangeReviewDiagnostic[] {
|
||||||
|
const next = [...diagnostics];
|
||||||
|
if (state.diffStatCompleteness === 'partial') {
|
||||||
|
this.addReviewDiagnostic(next, {
|
||||||
|
code: 'diff_stat_partial',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'Some file change statistics are incomplete.',
|
||||||
|
source: 'summary',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.integrity === 'partial') {
|
||||||
|
this.addReviewDiagnostic(next, {
|
||||||
|
code: 'ledger_integrity_partial',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger is partially available.',
|
||||||
|
source: 'ledger',
|
||||||
|
});
|
||||||
|
} else if (state.integrity === 'recovered') {
|
||||||
|
this.addReviewDiagnostic(next, {
|
||||||
|
code: 'ledger_integrity_recovered',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||||
|
source: 'ledger',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
private collectWarnings(
|
private collectWarnings(
|
||||||
events: LedgerEvent[],
|
events: LedgerEvent[],
|
||||||
notices: LedgerNotice[],
|
notices: LedgerNotice[],
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ import {
|
||||||
inspectOpenCodeRuntimeLaneStorage,
|
inspectOpenCodeRuntimeLaneStorage,
|
||||||
migrateLegacyOpenCodeRuntimeState,
|
migrateLegacyOpenCodeRuntimeState,
|
||||||
OpenCodeRuntimeManifestEvidenceReader,
|
OpenCodeRuntimeManifestEvidenceReader,
|
||||||
|
prepareOpenCodeRuntimeLaneForLaunchGeneration,
|
||||||
readCommittedOpenCodeBootstrapSessionEvidence,
|
readCommittedOpenCodeBootstrapSessionEvidence,
|
||||||
readOpenCodeRuntimeLaneIndex,
|
readOpenCodeRuntimeLaneIndex,
|
||||||
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
||||||
|
|
@ -9042,10 +9043,19 @@ export class TeamProvisioningService {
|
||||||
? error.code
|
? error.code
|
||||||
: 'opencode_attachment_delivery_prepare_failed';
|
: 'opencode_attachment_delivery_prepare_failed';
|
||||||
const diagnostic = `opencode_attachment_delivery_prepare_failed: ${getErrorMessage(error)}`;
|
const diagnostic = `opencode_attachment_delivery_prepare_failed: ${getErrorMessage(error)}`;
|
||||||
|
const userVisibleMessage =
|
||||||
|
error instanceof AgentAttachmentError
|
||||||
|
? error.message
|
||||||
|
: 'OpenCode could not prepare the attachment for live delivery.';
|
||||||
return {
|
return {
|
||||||
delivered: false,
|
delivered: false,
|
||||||
reason,
|
reason,
|
||||||
diagnostics: [diagnostic],
|
diagnostics: [diagnostic],
|
||||||
|
userVisibleImpact: {
|
||||||
|
state: 'error',
|
||||||
|
reasonCode: 'backend_error',
|
||||||
|
message: userVisibleMessage,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12288,6 +12298,8 @@ export class TeamProvisioningService {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
nowIso: string;
|
nowIso: string;
|
||||||
|
workSyncIntent?: 'agenda_sync' | 'review_pickup';
|
||||||
|
taskRefs?: TaskRef[];
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
|
@ -12318,7 +12330,10 @@ export class TeamProvisioningService {
|
||||||
const foregroundMessages = inboxMessages.filter(
|
const foregroundMessages = inboxMessages.filter(
|
||||||
(message) => message.messageKind !== 'member_work_sync_nudge'
|
(message) => message.messageKind !== 'member_work_sync_nudge'
|
||||||
);
|
);
|
||||||
const unreadForeground = foregroundMessages.find(
|
const blockingForegroundMessages = foregroundMessages.filter(
|
||||||
|
(message) => !this.isCurrentReviewPickupRequestForegroundMessage(message, input)
|
||||||
|
);
|
||||||
|
const unreadForeground = blockingForegroundMessages.find(
|
||||||
(message) =>
|
(message) =>
|
||||||
!message.read &&
|
!message.read &&
|
||||||
typeof message.text === 'string' &&
|
typeof message.text === 'string' &&
|
||||||
|
|
@ -12335,7 +12350,7 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentForeground = foregroundMessages.find((message) => {
|
const recentForeground = blockingForegroundMessages.find((message) => {
|
||||||
const timestampMs = Date.parse(message.timestamp);
|
const timestampMs = Date.parse(message.timestamp);
|
||||||
return Number.isFinite(timestampMs) && Number.isFinite(nowMs) && nowMs - timestampMs < 60_000;
|
return Number.isFinite(timestampMs) && Number.isFinite(nowMs) && nowMs - timestampMs < 60_000;
|
||||||
});
|
});
|
||||||
|
|
@ -12441,7 +12456,7 @@ export class TeamProvisioningService {
|
||||||
reasonCode: input.reason
|
reasonCode: input.reason
|
||||||
? classifyOpenCodeRuntimeDeliveryReasonCode(input.reason)
|
? classifyOpenCodeRuntimeDeliveryReasonCode(input.reason)
|
||||||
: undefined,
|
: undefined,
|
||||||
message: input.reason,
|
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (input.delivered === false) {
|
if (input.delivered === false) {
|
||||||
|
|
@ -12453,18 +12468,84 @@ export class TeamProvisioningService {
|
||||||
return {
|
return {
|
||||||
state: 'checking',
|
state: 'checking',
|
||||||
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
|
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
|
||||||
message: reason,
|
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
state: 'error',
|
state: 'error',
|
||||||
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
|
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
|
||||||
message: reason,
|
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return input.policyImpact ?? { state: 'none' };
|
return input.policyImpact ?? { state: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private selectOpenCodeRuntimeDeliveryUserVisibleMessage(input: {
|
||||||
|
reason?: string;
|
||||||
|
diagnostics?: string[];
|
||||||
|
}): string | undefined {
|
||||||
|
const attachmentMessage = this.selectOpenCodeAttachmentDeliveryUserVisibleMessage(input);
|
||||||
|
if (attachmentMessage) {
|
||||||
|
return attachmentMessage;
|
||||||
|
}
|
||||||
|
return input.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectOpenCodeAttachmentDeliveryUserVisibleMessage(input: {
|
||||||
|
reason?: string;
|
||||||
|
diagnostics?: string[];
|
||||||
|
}): string | undefined {
|
||||||
|
const reason = input.reason?.trim();
|
||||||
|
const isAttachmentFailure =
|
||||||
|
this.isOpenCodeAttachmentDeliveryFailureReason(reason) ||
|
||||||
|
input.diagnostics?.some((diagnostic) =>
|
||||||
|
diagnostic.trim().startsWith('opencode_attachment_delivery_prepare_failed:')
|
||||||
|
) === true;
|
||||||
|
if (!isAttachmentFailure) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnosticMessage = input.diagnostics
|
||||||
|
?.map((diagnostic) => diagnostic.trim())
|
||||||
|
.find((diagnostic) => diagnostic.startsWith('opencode_attachment_delivery_prepare_failed:'));
|
||||||
|
const strippedDiagnostic = diagnosticMessage
|
||||||
|
?.slice('opencode_attachment_delivery_prepare_failed:'.length)
|
||||||
|
.trim();
|
||||||
|
if (strippedDiagnostic) {
|
||||||
|
return strippedDiagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason === 'attachment_model_unsupported') {
|
||||||
|
return 'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.';
|
||||||
|
}
|
||||||
|
if (reason === 'attachment_type_unsupported') {
|
||||||
|
return 'This OpenCode model cannot receive this attachment type. Remove the attachment or choose a supported image model.';
|
||||||
|
}
|
||||||
|
if (reason === 'attachment_too_large') {
|
||||||
|
return 'The attachment is too large for live OpenCode delivery. Reduce the image size or remove the attachment.';
|
||||||
|
}
|
||||||
|
if (reason === 'attachment_artifact_missing' || reason === 'attachment_artifact_path_unsafe') {
|
||||||
|
return 'The attachment file is not available for live OpenCode delivery. Reattach the file and try again.';
|
||||||
|
}
|
||||||
|
if (reason === 'attachment_optimization_failed') {
|
||||||
|
return 'The attachment could not be optimized for live OpenCode delivery. Try a smaller image or remove the attachment.';
|
||||||
|
}
|
||||||
|
if (reason === 'attachment_provider_rejected') {
|
||||||
|
return 'The OpenCode provider rejected the attachment. Choose a different model or remove the attachment.';
|
||||||
|
}
|
||||||
|
if (reason === 'attachment_runtime_transport_failed') {
|
||||||
|
return 'OpenCode could not transport the attachment to the runtime. Try again or remove the attachment.';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOpenCodeAttachmentDeliveryFailureReason(reason: string | undefined): boolean {
|
||||||
|
return (
|
||||||
|
reason === 'opencode_attachment_delivery_prepare_failed' ||
|
||||||
|
reason?.startsWith('attachment_') === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private toOpenCodeRuntimeDeliveryStatus(
|
private toOpenCodeRuntimeDeliveryStatus(
|
||||||
record: OpenCodePromptDeliveryLedgerRecord,
|
record: OpenCodePromptDeliveryLedgerRecord,
|
||||||
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
|
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
|
||||||
|
|
@ -21395,8 +21476,9 @@ export class TeamProvisioningService {
|
||||||
...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_message_delivery_failed']),
|
...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_message_delivery_failed']),
|
||||||
];
|
];
|
||||||
if (
|
if (
|
||||||
delivery.reason !== 'opencode_runtime_not_active' ||
|
!this.isOpenCodeAttachmentDeliveryFailureReason(delivery.reason) &&
|
||||||
!this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName)
|
(delivery.reason !== 'opencode_runtime_not_active' ||
|
||||||
|
!this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName))
|
||||||
) {
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${teamName}] OpenCode inbox relay failed for ${memberName}/${message.messageId}: ${
|
`[${teamName}] OpenCode inbox relay failed for ${memberName}/${message.messageId}: ${
|
||||||
|
|
@ -21493,6 +21575,57 @@ export class TeamProvisioningService {
|
||||||
return typeof message.messageId === 'string' && message.messageId.trim().length > 0;
|
return typeof message.messageId === 'string' && message.messageId.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCurrentReviewPickupRequestForegroundMessage(
|
||||||
|
message: InboxMessage,
|
||||||
|
input: { workSyncIntent?: 'agenda_sync' | 'review_pickup'; taskRefs?: TaskRef[] }
|
||||||
|
): boolean {
|
||||||
|
if (input.workSyncIntent !== 'review_pickup') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message.source !== 'system_notification') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedRefs = this.normalizeOpenCodeTaskRefsForComparison(input.taskRefs);
|
||||||
|
if (expectedRefs.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = typeof message.summary === 'string' ? message.summary.trim() : '';
|
||||||
|
const text = typeof message.text === 'string' ? message.text : '';
|
||||||
|
const looksLikeReviewRequest =
|
||||||
|
summary.startsWith('Review request for #') ||
|
||||||
|
(text.includes('**Please review**') && text.includes('review_start'));
|
||||||
|
if (!looksLikeReviewRequest) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageRefs = this.normalizeOpenCodeTaskRefsForComparison(message.taskRefs);
|
||||||
|
if (messageRefs.length > 0) {
|
||||||
|
const expectedKeys = new Set(expectedRefs.map((taskRef) => this.openCodeTaskRefKey(taskRef)));
|
||||||
|
return messageRefs.some((taskRef) => expectedKeys.has(this.openCodeTaskRefKey(taskRef)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return expectedRefs.some((taskRef) =>
|
||||||
|
this.openCodeReviewPickupRequestTextMentionsTask({ summary, text, taskRef })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCodeReviewPickupRequestTextMentionsTask(input: {
|
||||||
|
summary: string;
|
||||||
|
text: string;
|
||||||
|
taskRef: TaskRef;
|
||||||
|
}): boolean {
|
||||||
|
const displayId = input.taskRef.displayId.trim();
|
||||||
|
const taskId = input.taskRef.taskId.trim();
|
||||||
|
const haystack = `${input.summary}\n${input.text}`;
|
||||||
|
return (
|
||||||
|
(displayId.length > 0 &&
|
||||||
|
(haystack.includes(`#${displayId}`) || haystack.includes(`task #${displayId}`))) ||
|
||||||
|
(taskId.length > 0 && haystack.includes(taskId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private isUserOriginatedLeadRelayMessage(message: InboxMessage): boolean {
|
private isUserOriginatedLeadRelayMessage(message: InboxMessage): boolean {
|
||||||
const from = typeof message.from === 'string' ? message.from.trim().toLowerCase() : '';
|
const from = typeof message.from === 'string' ? message.from.trim().toLowerCase() : '';
|
||||||
return from === 'user' || message.source === 'user_sent';
|
return from === 'user' || message.source === 'user_sent';
|
||||||
|
|
@ -25303,12 +25436,13 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
lane.state = 'launching';
|
lane.state = 'launching';
|
||||||
lane.runId = lane.runId ?? randomUUID();
|
lane.runId = lane.runId ?? randomUUID();
|
||||||
|
const laneRunId = lane.runId;
|
||||||
lane.warnings = [];
|
lane.warnings = [];
|
||||||
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics];
|
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics];
|
||||||
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
|
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
|
||||||
this.setSecondaryRuntimeRun({
|
this.setSecondaryRuntimeRun({
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
runId: lane.runId,
|
runId: laneRunId,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
laneId: lane.laneId,
|
laneId: lane.laneId,
|
||||||
memberName: lane.member.name,
|
memberName: lane.member.name,
|
||||||
|
|
@ -25322,11 +25456,12 @@ export class TeamProvisioningService {
|
||||||
await finishCancelledLane();
|
await finishCancelledLane();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await setOpenCodeRuntimeActiveRunManifest({
|
await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
laneId: lane.laneId,
|
laneId: lane.laneId,
|
||||||
runId: lane.runId,
|
runId: laneRunId,
|
||||||
|
reason: 'mixed_secondary_launch',
|
||||||
});
|
});
|
||||||
if (shouldAbortLaunch()) {
|
if (shouldAbortLaunch()) {
|
||||||
await finishCancelledLane();
|
await finishCancelledLane();
|
||||||
|
|
@ -25340,31 +25475,66 @@ export class TeamProvisioningService {
|
||||||
await finishCancelledLane();
|
await finishCancelledLane();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rawResult = await adapter.launch({
|
const launchOpenCodeLane = () =>
|
||||||
runId: lane.runId,
|
adapter.launch({
|
||||||
laneId: lane.laneId,
|
runId: laneRunId,
|
||||||
teamName: run.teamName,
|
laneId: lane.laneId,
|
||||||
cwd: laneCwd,
|
teamName: run.teamName,
|
||||||
prompt: appManagedLaunchPrompt,
|
cwd: laneCwd,
|
||||||
providerId: 'opencode',
|
prompt: appManagedLaunchPrompt,
|
||||||
model: lane.member.model,
|
providerId: 'opencode',
|
||||||
effort: lane.member.effort,
|
model: lane.member.model,
|
||||||
runtimeOnly: true,
|
effort: lane.member.effort,
|
||||||
skipPermissions: run.request.skipPermissions !== false,
|
runtimeOnly: true,
|
||||||
expectedMembers: [
|
skipPermissions: run.request.skipPermissions !== false,
|
||||||
{
|
expectedMembers: [
|
||||||
name: lane.member.name,
|
{
|
||||||
role: lane.member.role,
|
name: lane.member.name,
|
||||||
workflow: lane.member.workflow,
|
role: lane.member.role,
|
||||||
isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
workflow: lane.member.workflow,
|
||||||
providerId: 'opencode',
|
isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||||
model: lane.member.model,
|
providerId: 'opencode',
|
||||||
effort: lane.member.effort,
|
model: lane.member.model,
|
||||||
cwd: laneCwd,
|
effort: lane.member.effort,
|
||||||
},
|
cwd: laneCwd,
|
||||||
],
|
},
|
||||||
previousLaunchState,
|
],
|
||||||
});
|
previousLaunchState,
|
||||||
|
});
|
||||||
|
let rawResult: TeamRuntimeLaunchResult;
|
||||||
|
try {
|
||||||
|
rawResult = await launchOpenCodeLane();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const staleManifestMessage = 'Bridge server runtime manifest high watermark is stale';
|
||||||
|
if (
|
||||||
|
message !== staleManifestMessage &&
|
||||||
|
message !== `OpenCode bridge failed: ${staleManifestMessage}`
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (shouldAbortLaunch()) {
|
||||||
|
await finishCancelledLane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recovery = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
|
teamsBasePath: getTeamsBasePath(),
|
||||||
|
teamName: run.teamName,
|
||||||
|
laneId: lane.laneId,
|
||||||
|
runId: laneRunId,
|
||||||
|
reason: 'mixed_secondary_launch_stale_manifest_recovery',
|
||||||
|
forceReset: true,
|
||||||
|
});
|
||||||
|
lane.diagnostics = appendDiagnosticOnce(
|
||||||
|
[...lane.diagnostics, ...recovery.diagnostics],
|
||||||
|
'Retried OpenCode secondary launch after resetting stale runtime manifest.'
|
||||||
|
);
|
||||||
|
if (shouldAbortLaunch()) {
|
||||||
|
await finishCancelledLane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rawResult = await launchOpenCodeLane();
|
||||||
|
}
|
||||||
if (shouldAbortLaunch()) {
|
if (shouldAbortLaunch()) {
|
||||||
await finishCancelledLane();
|
await finishCancelledLane();
|
||||||
return;
|
return;
|
||||||
|
|
@ -25467,7 +25637,7 @@ export class TeamProvisioningService {
|
||||||
lane.launchFinishedAtMs = Date.now();
|
lane.launchFinishedAtMs = Date.now();
|
||||||
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
|
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
|
||||||
lane.result = {
|
lane.result = {
|
||||||
runId: lane.runId,
|
runId: laneRunId,
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
launchPhase: 'finished',
|
launchPhase: 'finished',
|
||||||
teamLaunchState: 'partial_failure',
|
teamLaunchState: 'partial_failure',
|
||||||
|
|
|
||||||
|
|
@ -140,4 +140,36 @@ export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepos
|
||||||
this.writeChains.set(teamName, next);
|
this.writeChains.set(teamName, next);
|
||||||
await next;
|
await next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteEntry(teamName: string, taskId: string): Promise<void> {
|
||||||
|
const write = async (): Promise<void> => {
|
||||||
|
const current = await this.readIndex(teamName);
|
||||||
|
if (!current?.entries[taskId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = { ...current.entries };
|
||||||
|
delete entries[taskId];
|
||||||
|
const next = toPersistedTaskChangePresenceIndex({
|
||||||
|
...current,
|
||||||
|
writtenAt: new Date().toISOString(),
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
|
||||||
|
await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const previous = this.writeChains.get(teamName) ?? Promise.resolve();
|
||||||
|
const next = previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(write)
|
||||||
|
.finally(() => {
|
||||||
|
if (this.writeChains.get(teamName) === next) {
|
||||||
|
this.writeChains.delete(teamName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.writeChains.set(teamName, next);
|
||||||
|
await next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,5 @@ export interface TaskChangePresenceRepository {
|
||||||
logSourceGeneration: string;
|
logSourceGeneration: string;
|
||||||
}
|
}
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
deleteEntry?(teamName: string, taskId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
|
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '@shared/types/review';
|
||||||
|
|
||||||
import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes';
|
import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes';
|
||||||
|
|
||||||
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
|
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
|
||||||
import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types';
|
import type {
|
||||||
|
FileChangeSummary,
|
||||||
|
TaskChangeJournalFileStamp,
|
||||||
|
TaskChangeJournalStamp,
|
||||||
|
TaskChangeProvenance,
|
||||||
|
TaskChangeReviewDiagnostic,
|
||||||
|
TaskChangeSetV2,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
const TASK_CHANGE_DIAGNOSTIC_CODE_SET = new Set<string>(TASK_CHANGE_DIAGNOSTIC_CODES);
|
||||||
|
|
||||||
|
function isTaskChangeDiagnosticCode(value: unknown): value is TaskChangeReviewDiagnostic['code'] {
|
||||||
|
return typeof value === 'string' && TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeIsoString(value: unknown): string | null {
|
function normalizeIsoString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string' || value.trim() === '') return null;
|
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||||
|
|
@ -31,6 +46,87 @@ function normalizeFileSummary(value: unknown): FileChangeSummary | null {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReviewDiagnostic(value: unknown): TaskChangeReviewDiagnostic | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const candidate = value as Partial<TaskChangeReviewDiagnostic>;
|
||||||
|
if (
|
||||||
|
!isTaskChangeDiagnosticCode(candidate.code) ||
|
||||||
|
(candidate.severity !== 'info' &&
|
||||||
|
candidate.severity !== 'warning' &&
|
||||||
|
candidate.severity !== 'error') ||
|
||||||
|
typeof candidate.reviewBlocking !== 'boolean' ||
|
||||||
|
typeof candidate.message !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: candidate.code,
|
||||||
|
severity: candidate.severity,
|
||||||
|
reviewBlocking: candidate.reviewBlocking,
|
||||||
|
message: candidate.message,
|
||||||
|
...(candidate.source === 'ledger' ||
|
||||||
|
candidate.source === 'legacy' ||
|
||||||
|
candidate.source === 'summary' ||
|
||||||
|
candidate.source === 'runtime'
|
||||||
|
? { source: candidate.source }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJournalFileStamp(value: unknown): TaskChangeJournalFileStamp | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const candidate = value as Partial<TaskChangeJournalFileStamp>;
|
||||||
|
if (!Number.isFinite(candidate.bytes) || !Number.isFinite(candidate.mtimeMs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes: Number(candidate.bytes),
|
||||||
|
mtimeMs: Number(candidate.mtimeMs),
|
||||||
|
tailSha256: typeof candidate.tailSha256 === 'string' ? candidate.tailSha256 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJournalStamp(value: unknown): TaskChangeJournalStamp | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const candidate = value as Partial<TaskChangeJournalStamp>;
|
||||||
|
const events = normalizeJournalFileStamp(candidate.events);
|
||||||
|
const notices = normalizeJournalFileStamp(candidate.notices);
|
||||||
|
if (!events && !notices) return undefined;
|
||||||
|
return {
|
||||||
|
...(events ? { events } : {}),
|
||||||
|
...(notices ? { notices } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProvenance(value: unknown): TaskChangeProvenance | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const candidate = value as Partial<TaskChangeProvenance>;
|
||||||
|
if (
|
||||||
|
(candidate.sourceKind !== 'ledger' && candidate.sourceKind !== 'legacy') ||
|
||||||
|
typeof candidate.sourceFingerprint !== 'string' ||
|
||||||
|
candidate.sourceFingerprint.trim() === ''
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalStamp = normalizeJournalStamp(candidate.journalStamp);
|
||||||
|
return {
|
||||||
|
sourceKind: candidate.sourceKind,
|
||||||
|
sourceFingerprint: candidate.sourceFingerprint,
|
||||||
|
...(journalStamp ? { journalStamp } : {}),
|
||||||
|
...(Number.isFinite(candidate.bundleSchemaVersion)
|
||||||
|
? { bundleSchemaVersion: Number(candidate.bundleSchemaVersion) }
|
||||||
|
: {}),
|
||||||
|
...(candidate.integrity === 'ok' ||
|
||||||
|
candidate.integrity === 'recovered' ||
|
||||||
|
candidate.integrity === 'partial'
|
||||||
|
? { integrity: candidate.integrity }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSummary(
|
function normalizeSummary(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -48,6 +144,16 @@ function normalizeSummary(
|
||||||
? candidate.confidence
|
? candidate.confidence
|
||||||
: null;
|
: null;
|
||||||
const computedAt = normalizeIsoString(candidate.computedAt);
|
const computedAt = normalizeIsoString(candidate.computedAt);
|
||||||
|
const reviewDiagnostics = Array.isArray(candidate.reviewDiagnostics)
|
||||||
|
? candidate.reviewDiagnostics
|
||||||
|
.map(normalizeReviewDiagnostic)
|
||||||
|
.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => diagnostic !== null)
|
||||||
|
: undefined;
|
||||||
|
const diffStatCompleteness =
|
||||||
|
candidate.diffStatCompleteness === 'complete' || candidate.diffStatCompleteness === 'partial'
|
||||||
|
? candidate.diffStatCompleteness
|
||||||
|
: undefined;
|
||||||
|
const provenance = normalizeProvenance(candidate.provenance);
|
||||||
if (
|
if (
|
||||||
!files ||
|
!files ||
|
||||||
!confidence ||
|
!confidence ||
|
||||||
|
|
@ -75,6 +181,9 @@ function normalizeSummary(
|
||||||
warnings: candidate.warnings.filter(
|
warnings: candidate.warnings.filter(
|
||||||
(warning): warning is string => typeof warning === 'string'
|
(warning): warning is string => typeof warning === 'string'
|
||||||
),
|
),
|
||||||
|
...(reviewDiagnostics ? { reviewDiagnostics } : {}),
|
||||||
|
...(diffStatCompleteness ? { diffStatCompleteness } : {}),
|
||||||
|
...(provenance ? { provenance } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,127 @@ export async function inspectOpenCodeRuntimeLaneStorage(params: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeRuntimeLaneLaunchGenerationPreparation {
|
||||||
|
reset: boolean;
|
||||||
|
reason:
|
||||||
|
| 'fresh_manifest_created'
|
||||||
|
| 'same_generation_reused'
|
||||||
|
| 'forced_reset'
|
||||||
|
| 'manifest_unreadable'
|
||||||
|
| 'lane_index_terminal'
|
||||||
|
| 'active_run_mismatch'
|
||||||
|
| 'stale_manifest_entries';
|
||||||
|
diagnostics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareOpenCodeRuntimeLaneForLaunchGeneration(params: {
|
||||||
|
teamsBasePath: string;
|
||||||
|
teamName: string;
|
||||||
|
laneId: string;
|
||||||
|
runId: string;
|
||||||
|
reason: string;
|
||||||
|
forceReset?: boolean;
|
||||||
|
clock?: () => Date;
|
||||||
|
}): Promise<OpenCodeRuntimeLaneLaunchGenerationPreparation> {
|
||||||
|
const clock = params.clock ?? (() => new Date());
|
||||||
|
const manifestPath = getOpenCodeRuntimeManifestPath(
|
||||||
|
params.teamsBasePath,
|
||||||
|
params.teamName,
|
||||||
|
params.laneId
|
||||||
|
);
|
||||||
|
const laneIndex = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
const laneIndexEntry = laneIndex?.lanes[params.laneId] ?? null;
|
||||||
|
const terminalLaneIndex =
|
||||||
|
laneIndexEntry?.state === 'degraded' || laneIndexEntry?.state === 'stopped';
|
||||||
|
|
||||||
|
let manifest: Awaited<ReturnType<typeof readRuntimeStoreManifestEvidenceData>> | null = null;
|
||||||
|
let manifestUnreadable = false;
|
||||||
|
if (await fileExists(manifestPath)) {
|
||||||
|
try {
|
||||||
|
manifest = await readRuntimeStoreManifestEvidenceData(manifestPath, params.teamName, clock);
|
||||||
|
} catch {
|
||||||
|
manifestUnreadable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const staleEntryRunIds =
|
||||||
|
manifest?.entries
|
||||||
|
.filter((entry) => entry.runId !== params.runId)
|
||||||
|
.map((entry) => entry.runId ?? 'none') ?? [];
|
||||||
|
const activeRunMismatch = Boolean(manifest && manifest.activeRunId !== params.runId);
|
||||||
|
const shouldReset =
|
||||||
|
params.forceReset ||
|
||||||
|
manifestUnreadable ||
|
||||||
|
terminalLaneIndex ||
|
||||||
|
activeRunMismatch ||
|
||||||
|
staleEntryRunIds.length > 0;
|
||||||
|
|
||||||
|
let reason: OpenCodeRuntimeLaneLaunchGenerationPreparation['reason'];
|
||||||
|
const diagnostics: string[] = [];
|
||||||
|
if (params.forceReset) {
|
||||||
|
reason = 'forced_reset';
|
||||||
|
diagnostics.push(
|
||||||
|
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: forced reset requested.`
|
||||||
|
);
|
||||||
|
} else if (manifestUnreadable) {
|
||||||
|
reason = 'manifest_unreadable';
|
||||||
|
diagnostics.push(
|
||||||
|
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: runtime manifest could not be read.`
|
||||||
|
);
|
||||||
|
} else if (terminalLaneIndex) {
|
||||||
|
reason = 'lane_index_terminal';
|
||||||
|
diagnostics.push(
|
||||||
|
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: previous lane state was ${laneIndexEntry?.state}.`
|
||||||
|
);
|
||||||
|
} else if (activeRunMismatch) {
|
||||||
|
reason = 'active_run_mismatch';
|
||||||
|
diagnostics.push(
|
||||||
|
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: active run changed from ${manifest?.activeRunId ?? 'none'} to ${params.runId}.`
|
||||||
|
);
|
||||||
|
} else if (staleEntryRunIds.length > 0) {
|
||||||
|
reason = 'stale_manifest_entries';
|
||||||
|
diagnostics.push(
|
||||||
|
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: runtime manifest contained entries from previous run ${Array.from(new Set(staleEntryRunIds)).join(', ')}.`
|
||||||
|
);
|
||||||
|
} else if (!manifest) {
|
||||||
|
reason = 'fresh_manifest_created';
|
||||||
|
diagnostics.push(`Prepared fresh OpenCode runtime lane ${params.laneId} for ${params.reason}.`);
|
||||||
|
} else {
|
||||||
|
reason = 'same_generation_reused';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReset) {
|
||||||
|
await clearOpenCodeRuntimeLaneStorage({
|
||||||
|
teamsBasePath: params.teamsBasePath,
|
||||||
|
teamName: params.teamName,
|
||||||
|
laneId: params.laneId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
|
teamsBasePath: params.teamsBasePath,
|
||||||
|
teamName: params.teamName,
|
||||||
|
laneId: params.laneId,
|
||||||
|
state: 'active',
|
||||||
|
diagnostics: diagnostics.length ? diagnostics : undefined,
|
||||||
|
});
|
||||||
|
await setOpenCodeRuntimeActiveRunManifest({
|
||||||
|
teamsBasePath: params.teamsBasePath,
|
||||||
|
teamName: params.teamName,
|
||||||
|
laneId: params.laneId,
|
||||||
|
runId: params.runId,
|
||||||
|
clock,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset: shouldReset,
|
||||||
|
reason,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getOpenCodeLaneScopedRuntimeFilePath(params: {
|
export function getOpenCodeLaneScopedRuntimeFilePath(params: {
|
||||||
teamsBasePath: string;
|
teamsBasePath: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
import { AlertTriangle, FileDiff, GitCompareArrows, Loader2, RefreshCw } from 'lucide-react';
|
import { AlertTriangle, FileDiff, GitCompareArrows, Info, Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
import { FileIcon } from './editor/FileIcon';
|
import { FileIcon } from './editor/FileIcon';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
|
|
@ -59,11 +60,25 @@ function getVisibleFileName(file: FileChangeSummary): string {
|
||||||
|
|
||||||
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
|
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
|
||||||
if (!changeSet) return undefined;
|
if (!changeSet) return undefined;
|
||||||
|
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
||||||
if (changeSet.totalFiles > 0) return `${changeSet.totalFiles} files`;
|
if (changeSet.totalFiles > 0) return `${changeSet.totalFiles} files`;
|
||||||
if (changeSet.warnings.length > 0) return 'attention';
|
if (reviewability === 'attention_required') return 'attention';
|
||||||
|
if (reviewability === 'diagnostic_only') return 'no safe diff';
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
||||||
|
const status = classifyTaskChangeReviewability(changeSet);
|
||||||
|
if (status.reviewability === 'unknown' || status.reviewability === 'none') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const messages =
|
||||||
|
status.diagnostics.length > 0
|
||||||
|
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||||
|
: changeSet.warnings;
|
||||||
|
return [...new Set(messages.filter((message) => message.trim().length > 0))];
|
||||||
|
}
|
||||||
|
|
||||||
export const TeamChangesSection = memo(function TeamChangesSection({
|
export const TeamChangesSection = memo(function TeamChangesSection({
|
||||||
teamName,
|
teamName,
|
||||||
tasks,
|
tasks,
|
||||||
|
|
@ -82,13 +97,15 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
||||||
const visibleSummaries = useMemo(() => {
|
const visibleSummaries = useMemo(() => {
|
||||||
return Object.values(summariesByTaskId)
|
return Object.values(summariesByTaskId)
|
||||||
.map((summary) => ({ summary, task: taskMap.get(summary.taskId) }))
|
.map((summary) => ({ summary, task: taskMap.get(summary.taskId) }))
|
||||||
.filter(
|
.filter((entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } => {
|
||||||
(entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } =>
|
const changeSet = entry.summary.changeSet;
|
||||||
|
return (
|
||||||
Boolean(entry.task) &&
|
Boolean(entry.task) &&
|
||||||
(Boolean(entry.summary.error) ||
|
(Boolean(entry.summary.error) ||
|
||||||
(entry.summary.changeSet?.files.length ?? 0) > 0 ||
|
(changeSet?.files.length ?? 0) > 0 ||
|
||||||
(entry.summary.changeSet?.warnings.length ?? 0) > 0)
|
(changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false))
|
||||||
)
|
);
|
||||||
|
})
|
||||||
.sort((a, b) => getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task));
|
.sort((a, b) => getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task));
|
||||||
}, [summariesByTaskId, taskMap]);
|
}, [summariesByTaskId, taskMap]);
|
||||||
|
|
||||||
|
|
@ -163,13 +180,19 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
||||||
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
|
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
|
||||||
const changeSet = summary.changeSet;
|
const changeSet = summary.changeSet;
|
||||||
const files = changeSet?.files ?? [];
|
const files = changeSet?.files ?? [];
|
||||||
|
const reviewability = changeSet
|
||||||
|
? classifyTaskChangeReviewability(changeSet).reviewability
|
||||||
|
: 'unknown';
|
||||||
const contributors = getTaskChangeContributors(task, changeSet);
|
const contributors = getTaskChangeContributors(task, changeSet);
|
||||||
const contributorLabel =
|
const contributorLabel =
|
||||||
contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';
|
contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';
|
||||||
const extraContributors = Math.max(0, contributors.length - 3);
|
const extraContributors = Math.max(0, contributors.length - 3);
|
||||||
const badgeText = getTaskSummaryBadge(changeSet);
|
const badgeText = getTaskSummaryBadge(changeSet);
|
||||||
|
const diagnosticMessages = changeSet
|
||||||
|
? getTaskChangeDiagnosticMessages(changeSet)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (visibleFiles.length === 0 && !summary.error && !changeSet?.warnings.length) {
|
if (visibleFiles.length === 0 && !summary.error && diagnosticMessages.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,15 +233,23 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{changeSet?.warnings.length ? (
|
{diagnosticMessages.length ? (
|
||||||
<div className="space-y-1 border-t border-[var(--color-border)] px-2 py-1.5">
|
<div className="space-y-1 border-t border-[var(--color-border)] px-2 py-1.5">
|
||||||
{changeSet.warnings.slice(0, 2).map((warning) => (
|
{diagnosticMessages.slice(0, 2).map((message) => (
|
||||||
<div
|
<div
|
||||||
key={warning}
|
key={message}
|
||||||
className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]"
|
className={`flex items-center gap-2 text-xs ${
|
||||||
|
reviewability === 'attention_required'
|
||||||
|
? 'text-[var(--step-warning-text)]'
|
||||||
|
: 'text-[var(--color-text-muted)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<AlertTriangle size={13} className="shrink-0" />
|
{reviewability === 'attention_required' ? (
|
||||||
<span className="min-w-0 truncate">{warning}</span>
|
<AlertTriangle size={13} className="shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Info size={13} className="shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 truncate">{message}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { withTeamChangesLoadTimeout } from '../teamChangesLoadTimeout';
|
||||||
|
|
||||||
|
describe('withTeamChangesLoadTimeout', () => {
|
||||||
|
it('resolves when the request finishes before the timeout', async () => {
|
||||||
|
await expect(withTeamChangesLoadTimeout(Promise.resolve('ok'), 100)).resolves.toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the request does not finish before the timeout', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const request = withTeamChangesLoadTimeout(new Promise(() => undefined), 1000);
|
||||||
|
const expectation = expect(request).rejects.toThrow('Team changes request timed out');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
await expectation;
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,13 @@ import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||||
|
import {
|
||||||
|
canMemberShowAttachmentControl,
|
||||||
|
getAttachmentInputAcceptForMember,
|
||||||
|
getMemberAttachmentUnavailableReason,
|
||||||
|
validateAttachmentFilesForMember,
|
||||||
|
validateAttachmentPayloadsForMember,
|
||||||
|
} from '@renderer/utils/attachmentRecipientCapabilities';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -147,19 +154,26 @@ export const SendMessageDialog = ({
|
||||||
normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
|
normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
|
||||||
inferTeamProviderIdFromModel(selectedMember?.model);
|
inferTeamProviderIdFromModel(selectedMember?.model);
|
||||||
const isOpenCodeRecipient = selectedProviderId === 'opencode';
|
const isOpenCodeRecipient = selectedProviderId === 'opencode';
|
||||||
|
const showAttachmentControl = canMemberShowAttachmentControl(selectedMember);
|
||||||
|
const memberAttachmentUnavailableReason = showAttachmentControl
|
||||||
|
? getMemberAttachmentUnavailableReason(selectedMember)
|
||||||
|
: null;
|
||||||
|
const attachmentInputAccept = getAttachmentInputAcceptForMember(selectedMember);
|
||||||
const hasTeammates = members.length > 1;
|
const hasTeammates = members.length > 1;
|
||||||
const canDelegate = hasTeammates && isLeadRecipient;
|
const canDelegate = hasTeammates && isLeadRecipient;
|
||||||
const shouldAutoDelegate = canDelegate;
|
const shouldAutoDelegate = canDelegate;
|
||||||
const supportsAttachments = !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient);
|
const supportsAttachments =
|
||||||
|
!!isTeamAlive && showAttachmentControl && memberAttachmentUnavailableReason == null;
|
||||||
const canAttach = supportsAttachments && canAddMore;
|
const canAttach = supportsAttachments && canAddMore;
|
||||||
const attachmentRestrictionReason = !supportsAttachments
|
const attachmentRestrictionReason = !supportsAttachments
|
||||||
? !isTeamAlive
|
? !isTeamAlive
|
||||||
? 'Team must be online to attach files'
|
? 'Team must be online to attach files'
|
||||||
: !isLeadRecipient && !isOpenCodeRecipient
|
: !showAttachmentControl
|
||||||
? 'Files can be sent to the team lead or OpenCode teammates'
|
? 'Files can be sent to the team lead or OpenCode teammates'
|
||||||
: isOpenCodeRecipient
|
: (memberAttachmentUnavailableReason ??
|
||||||
? 'Team must be online to attach files for OpenCode teammates'
|
(isOpenCodeRecipient
|
||||||
: 'Team must be online to attach files'
|
? 'Team must be online to attach files for OpenCode teammates'
|
||||||
|
: 'Team must be online to attach files'))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Auto-switch to delegate when lead recipient is selected, but don't
|
// Auto-switch to delegate when lead recipient is selected, but don't
|
||||||
|
|
@ -257,7 +271,12 @@ export const SendMessageDialog = ({
|
||||||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||||
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
|
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
|
||||||
|
|
||||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
const attachmentPayloadRestrictionReason = validateAttachmentPayloadsForMember({
|
||||||
|
member: selectedMember,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
const attachmentsBlocked =
|
||||||
|
attachments.length > 0 && (!supportsAttachments || attachmentPayloadRestrictionReason != null);
|
||||||
|
|
||||||
const trimmedText = stripEncodedTaskReferenceMetadata(textDraft.value).trim();
|
const trimmedText = stripEncodedTaskReferenceMetadata(textDraft.value).trim();
|
||||||
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
|
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
|
||||||
|
|
@ -313,13 +332,34 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
const showFileRestrictionError = useCallback(() => {
|
const showFileRestrictionError = useCallback(() => {
|
||||||
setFileRestrictionError(
|
setFileRestrictionError(
|
||||||
attachmentRestrictionReason ?? 'Files can be sent to the team lead or OpenCode teammates'
|
attachmentRestrictionReason ??
|
||||||
|
attachmentPayloadRestrictionReason ??
|
||||||
|
'Files can be sent to the team lead or OpenCode teammates'
|
||||||
);
|
);
|
||||||
window.clearTimeout(fileRestrictionTimerRef.current);
|
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||||
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||||
setFileRestrictionError(null);
|
setFileRestrictionError(null);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}, [attachmentRestrictionReason]);
|
}, [attachmentPayloadRestrictionReason, attachmentRestrictionReason]);
|
||||||
|
|
||||||
|
const validateSelectedAttachmentFiles = useCallback(
|
||||||
|
(files: FileList | File[]): boolean => {
|
||||||
|
const reason = validateAttachmentFilesForMember({
|
||||||
|
member: selectedMember,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
if (!reason) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setFileRestrictionError(reason);
|
||||||
|
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||||
|
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||||
|
setFileRestrictionError(null);
|
||||||
|
}, 4000);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedMember]
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileInputChange = useCallback(
|
const handleFileInputChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -330,11 +370,15 @@ export const SendMessageDialog = ({
|
||||||
input.value = '';
|
input.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!validateSelectedAttachmentFiles(input.files)) {
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
void addFiles(input.files);
|
void addFiles(input.files);
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
},
|
},
|
||||||
[addFiles, canAttach, showFileRestrictionError]
|
[addFiles, canAttach, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup restriction error timer on unmount
|
// Cleanup restriction error timer on unmount
|
||||||
|
|
@ -374,9 +418,13 @@ export const SendMessageDialog = ({
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files?.length && !validateSelectedAttachmentFiles(files)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleDrop(e);
|
handleDrop(e);
|
||||||
},
|
},
|
||||||
[supportsAttachments, handleDrop, showFileRestrictionError]
|
[supportsAttachments, handleDrop, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePasteWrapper = useCallback(
|
const handlePasteWrapper = useCallback(
|
||||||
|
|
@ -389,9 +437,17 @@ export const SendMessageDialog = ({
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const pastedFiles = Array.from(e.clipboardData.items)
|
||||||
|
.filter((item) => item.kind === 'file')
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => file != null);
|
||||||
|
if (pastedFiles.length > 0 && !validateSelectedAttachmentFiles(pastedFiles)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
handlePaste(e);
|
handlePaste(e);
|
||||||
},
|
},
|
||||||
[supportsAttachments, handlePaste, showFileRestrictionError]
|
[supportsAttachments, handlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -430,12 +486,12 @@ export const SendMessageDialog = ({
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor="smd-message">Message</Label>
|
<Label htmlFor="smd-message">Message</Label>
|
||||||
{isLeadRecipient ? (
|
{showAttachmentControl ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="*/*"
|
accept={attachmentInputAccept}
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
|
|
@ -468,10 +524,14 @@ export const SendMessageDialog = ({
|
||||||
<AttachmentPreviewList
|
<AttachmentPreviewList
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
error={attachmentError ?? fileRestrictionError}
|
error={attachmentError ?? fileRestrictionError ?? attachmentPayloadRestrictionReason}
|
||||||
onDismissError={clearAttachmentError}
|
onDismissError={clearAttachmentError}
|
||||||
disabled={attachmentsBlocked}
|
disabled={attachmentsBlocked}
|
||||||
disabledHint="File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient."
|
disabledHint={
|
||||||
|
attachmentPayloadRestrictionReason ??
|
||||||
|
attachmentRestrictionReason ??
|
||||||
|
'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import {
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||||
import {
|
import {
|
||||||
deriveTaskDisplayId,
|
deriveTaskDisplayId,
|
||||||
formatTaskDisplayLabel,
|
formatTaskDisplayLabel,
|
||||||
|
|
@ -82,6 +83,7 @@ import {
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
History,
|
History,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
Info,
|
||||||
Link2,
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
|
@ -107,6 +109,7 @@ import type {
|
||||||
KanbanTaskState,
|
KanbanTaskState,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
|
TaskChangeReviewability,
|
||||||
TaskChangeSetV2,
|
TaskChangeSetV2,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -168,6 +171,8 @@ export const TaskDetailDialog = ({
|
||||||
const [changesSectionOpen, setChangesSectionOpen] = useState(false);
|
const [changesSectionOpen, setChangesSectionOpen] = useState(false);
|
||||||
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
||||||
const [taskChangesWarnings, setTaskChangesWarnings] = useState<string[]>([]);
|
const [taskChangesWarnings, setTaskChangesWarnings] = useState<string[]>([]);
|
||||||
|
const [taskChangesReviewability, setTaskChangesReviewability] =
|
||||||
|
useState<TaskChangeReviewability | null>(null);
|
||||||
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
||||||
const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
|
const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
|
||||||
const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null);
|
const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null);
|
||||||
|
|
@ -238,6 +243,7 @@ export const TaskDetailDialog = ({
|
||||||
setChangesSectionOpen(false);
|
setChangesSectionOpen(false);
|
||||||
setTaskChangesFiles(null);
|
setTaskChangesFiles(null);
|
||||||
setTaskChangesWarnings([]);
|
setTaskChangesWarnings([]);
|
||||||
|
setTaskChangesReviewability(null);
|
||||||
setTaskChangesLoading(false);
|
setTaskChangesLoading(false);
|
||||||
setTaskChangesError(null);
|
setTaskChangesError(null);
|
||||||
setLogsRefreshing(false);
|
setLogsRefreshing(false);
|
||||||
|
|
@ -395,7 +401,15 @@ export const TaskDetailDialog = ({
|
||||||
const syncTaskChangeSummaryResult = useCallback(
|
const syncTaskChangeSummaryResult = useCallback(
|
||||||
(data: TaskChangeSetV2 | null) => {
|
(data: TaskChangeSetV2 | null) => {
|
||||||
setTaskChangesFiles(data?.files ?? null);
|
setTaskChangesFiles(data?.files ?? null);
|
||||||
setTaskChangesWarnings(data?.warnings ?? []);
|
const status = data ? classifyTaskChangeReviewability(data) : null;
|
||||||
|
const diagnosticMessages =
|
||||||
|
status && status.diagnostics.length > 0
|
||||||
|
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||||
|
: (data?.warnings ?? []);
|
||||||
|
setTaskChangesWarnings([
|
||||||
|
...new Set(diagnosticMessages.filter((message) => message.trim().length > 0)),
|
||||||
|
]);
|
||||||
|
setTaskChangesReviewability(status?.reviewability ?? null);
|
||||||
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
||||||
if (currentTask && taskChangeRequestOptions) {
|
if (currentTask && taskChangeRequestOptions) {
|
||||||
recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence);
|
recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence);
|
||||||
|
|
@ -446,6 +460,7 @@ export const TaskDetailDialog = ({
|
||||||
if (!preserveFilesOnError) {
|
if (!preserveFilesOnError) {
|
||||||
setTaskChangesFiles(null);
|
setTaskChangesFiles(null);
|
||||||
setTaskChangesWarnings([]);
|
setTaskChangesWarnings([]);
|
||||||
|
setTaskChangesReviewability(null);
|
||||||
}
|
}
|
||||||
setTaskChangesError(
|
setTaskChangesError(
|
||||||
error instanceof Error ? error.message : 'Failed to load task changes summary'
|
error instanceof Error ? error.message : 'Failed to load task changes summary'
|
||||||
|
|
@ -592,7 +607,11 @@ export const TaskDetailDialog = ({
|
||||||
? taskChangesFiles && taskChangesFiles.length > 0
|
? taskChangesFiles && taskChangesFiles.length > 0
|
||||||
? taskChangesFiles.length
|
? taskChangesFiles.length
|
||||||
: taskChangesFiles && taskChangesWarnings.length > 0
|
: taskChangesFiles && taskChangesWarnings.length > 0
|
||||||
? 'attention'
|
? taskChangesReviewability === 'attention_required'
|
||||||
|
? 'attention'
|
||||||
|
: taskChangesReviewability === 'diagnostic_only'
|
||||||
|
? 'no safe diff'
|
||||||
|
: undefined
|
||||||
: undefined
|
: undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|
@ -1245,19 +1264,33 @@ export const TaskDetailDialog = ({
|
||||||
) : taskChangesFiles ? (
|
) : taskChangesFiles ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{taskChangesWarnings.length > 0 ? (
|
{taskChangesWarnings.length > 0 ? (
|
||||||
<div className="space-y-1 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5">
|
<div
|
||||||
|
className={`space-y-1 rounded-md border px-2 py-1.5 ${
|
||||||
|
taskChangesReviewability === 'attention_required'
|
||||||
|
? 'border-amber-500/20 bg-amber-500/10'
|
||||||
|
: 'border-[var(--color-border)] bg-[var(--color-bg-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{taskChangesWarnings.slice(0, 2).map((warning) => (
|
{taskChangesWarnings.slice(0, 2).map((warning) => (
|
||||||
<div
|
<div
|
||||||
key={warning}
|
key={warning}
|
||||||
className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]"
|
className={`flex items-center gap-2 text-xs ${
|
||||||
|
taskChangesReviewability === 'attention_required'
|
||||||
|
? 'text-[var(--step-warning-text)]'
|
||||||
|
: 'text-[var(--color-text-muted)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<AlertTriangle size={13} className="shrink-0" />
|
{taskChangesReviewability === 'attention_required' ? (
|
||||||
|
<AlertTriangle size={13} className="shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Info size={13} className="shrink-0" />
|
||||||
|
)}
|
||||||
<span className="min-w-0 truncate">{warning}</span>
|
<span className="min-w-0 truncate">{warning}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{taskChangesWarnings.length > 2 ? (
|
{taskChangesWarnings.length > 2 ? (
|
||||||
<p className="text-[10px] text-[var(--color-text-muted)]">
|
<p className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
{taskChangesWarnings.length - 2} more warnings
|
{taskChangesWarnings.length - 2} more diagnostics
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1337,7 +1370,11 @@ export const TaskDetailDialog = ({
|
||||||
) : changesSectionOpen ? (
|
) : changesSectionOpen ? (
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
{taskChangesWarnings.length > 0
|
{taskChangesWarnings.length > 0
|
||||||
? 'No reviewable file changes recovered'
|
? taskChangesReviewability === 'attention_required'
|
||||||
|
? 'No reviewable file changes recovered'
|
||||||
|
: taskChangesReviewability === 'diagnostic_only'
|
||||||
|
? 'No safe diff available'
|
||||||
|
: 'No file changes recorded yet'
|
||||||
: 'No file changes recorded'}
|
: 'No file changes recorded'}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
|
import {
|
||||||
|
canMemberShowAttachmentControl,
|
||||||
|
getAttachmentInputAcceptForMember,
|
||||||
|
getMemberAttachmentUnavailableReason,
|
||||||
|
validateAttachmentFilesForMember,
|
||||||
|
validateAttachmentPayloadsForMember,
|
||||||
|
} from '@renderer/utils/attachmentRecipientCapabilities';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
import { isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||||
|
|
@ -288,6 +295,11 @@ export const MessageComposer = ({
|
||||||
normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
|
normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
|
||||||
inferTeamProviderIdFromModel(selectedMember?.model);
|
inferTeamProviderIdFromModel(selectedMember?.model);
|
||||||
const isOpenCodeRecipient = selectedProviderId === 'opencode';
|
const isOpenCodeRecipient = selectedProviderId === 'opencode';
|
||||||
|
const showAttachmentControl = canMemberShowAttachmentControl(selectedMember);
|
||||||
|
const memberAttachmentUnavailableReason = showAttachmentControl
|
||||||
|
? getMemberAttachmentUnavailableReason(selectedMember)
|
||||||
|
: null;
|
||||||
|
const attachmentInputAccept = getAttachmentInputAcceptForMember(selectedMember);
|
||||||
const hasTeammates = members.length > 1;
|
const hasTeammates = members.length > 1;
|
||||||
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
|
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
|
||||||
const shouldAutoDelegate = isLeadRecipient && canDelegate;
|
const shouldAutoDelegate = isLeadRecipient && canDelegate;
|
||||||
|
|
@ -343,24 +355,34 @@ export const MessageComposer = ({
|
||||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||||
// );
|
// );
|
||||||
const supportsAttachments =
|
const supportsAttachments =
|
||||||
!isCrossTeam && !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient);
|
!isCrossTeam &&
|
||||||
|
!!isTeamAlive &&
|
||||||
|
showAttachmentControl &&
|
||||||
|
memberAttachmentUnavailableReason == null;
|
||||||
const canAttach = supportsAttachments && draft.canAddMore && !sending;
|
const canAttach = supportsAttachments && draft.canAddMore && !sending;
|
||||||
const attachmentRestrictionReason = !supportsAttachments
|
const attachmentRestrictionReason = !supportsAttachments
|
||||||
? isCrossTeam
|
? isCrossTeam
|
||||||
? 'File attachments are not supported for cross-team messages'
|
? 'File attachments are not supported for cross-team messages'
|
||||||
: !isTeamAlive
|
: !isTeamAlive
|
||||||
? 'Team must be online to attach files'
|
? 'Team must be online to attach files'
|
||||||
: !isLeadRecipient && !isOpenCodeRecipient
|
: !showAttachmentControl
|
||||||
? 'Files can be sent to the team lead or OpenCode teammates'
|
? 'Files can be sent to the team lead or OpenCode teammates'
|
||||||
: isOpenCodeRecipient
|
: (memberAttachmentUnavailableReason ??
|
||||||
? 'Team must be online to attach files for OpenCode teammates'
|
(isOpenCodeRecipient
|
||||||
: 'Team must be online to attach files'
|
? 'Team must be online to attach files for OpenCode teammates'
|
||||||
|
: 'Team must be online to attach files'))
|
||||||
: sending
|
: sending
|
||||||
? 'Wait for current message to finish sending before adding files'
|
? 'Wait for current message to finish sending before adding files'
|
||||||
: !draft.canAddMore
|
: !draft.canAddMore
|
||||||
? 'Maximum attachments reached'
|
? 'Maximum attachments reached'
|
||||||
: undefined;
|
: undefined;
|
||||||
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
|
const attachmentPayloadRestrictionReason = validateAttachmentPayloadsForMember({
|
||||||
|
member: selectedMember,
|
||||||
|
attachments: draft.attachments,
|
||||||
|
});
|
||||||
|
const attachmentsBlocked =
|
||||||
|
draft.attachments.length > 0 &&
|
||||||
|
(!supportsAttachments || attachmentPayloadRestrictionReason != null);
|
||||||
const slashCommandRestrictionReason = standaloneSlashCommand
|
const slashCommandRestrictionReason = standaloneSlashCommand
|
||||||
? draft.attachments.length > 0
|
? draft.attachments.length > 0
|
||||||
? 'Slash commands require a live team lead and cannot be sent with attachments'
|
? 'Slash commands require a live team lead and cannot be sent with attachments'
|
||||||
|
|
@ -500,13 +522,34 @@ export const MessageComposer = ({
|
||||||
|
|
||||||
const showFileRestrictionError = useCallback(() => {
|
const showFileRestrictionError = useCallback(() => {
|
||||||
setFileRestrictionError(
|
setFileRestrictionError(
|
||||||
attachmentRestrictionReason ?? 'Files can only be sent to the team lead'
|
attachmentRestrictionReason ??
|
||||||
|
attachmentPayloadRestrictionReason ??
|
||||||
|
'Files can only be sent to the team lead'
|
||||||
);
|
);
|
||||||
window.clearTimeout(fileRestrictionTimerRef.current);
|
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||||
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||||
setFileRestrictionError(null);
|
setFileRestrictionError(null);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}, [attachmentRestrictionReason]);
|
}, [attachmentPayloadRestrictionReason, attachmentRestrictionReason]);
|
||||||
|
|
||||||
|
const validateSelectedAttachmentFiles = useCallback(
|
||||||
|
(files: FileList | File[]): boolean => {
|
||||||
|
const reason = validateAttachmentFilesForMember({
|
||||||
|
member: selectedMember,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
if (!reason) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setFileRestrictionError(reason);
|
||||||
|
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||||
|
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||||
|
setFileRestrictionError(null);
|
||||||
|
}, 4000);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedMember]
|
||||||
|
);
|
||||||
|
|
||||||
const { addFiles: draftAddFiles } = draft;
|
const { addFiles: draftAddFiles } = draft;
|
||||||
const handleFileInputChange = useCallback(
|
const handleFileInputChange = useCallback(
|
||||||
|
|
@ -518,11 +561,15 @@ export const MessageComposer = ({
|
||||||
input.value = '';
|
input.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!validateSelectedAttachmentFiles(input.files)) {
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
void draftAddFiles(input.files);
|
void draftAddFiles(input.files);
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
},
|
},
|
||||||
[canAttach, draftAddFiles, showFileRestrictionError]
|
[canAttach, draftAddFiles, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup restriction error timer on unmount
|
// Cleanup restriction error timer on unmount
|
||||||
|
|
@ -563,9 +610,13 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files?.length && !validateSelectedAttachmentFiles(files)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
draftHandleDrop(e);
|
draftHandleDrop(e);
|
||||||
},
|
},
|
||||||
[canAttach, draftHandleDrop, showFileRestrictionError]
|
[canAttach, draftHandleDrop, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { handlePaste: draftHandlePaste } = draft;
|
const { handlePaste: draftHandlePaste } = draft;
|
||||||
|
|
@ -579,9 +630,17 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const pastedFiles = Array.from(e.clipboardData.items)
|
||||||
|
.filter((item) => item.kind === 'file')
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => file != null);
|
||||||
|
if (pastedFiles.length > 0 && !validateSelectedAttachmentFiles(pastedFiles)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
draftHandlePaste(e);
|
draftHandlePaste(e);
|
||||||
},
|
},
|
||||||
[canAttach, draftHandlePaste, showFileRestrictionError]
|
[canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||||
|
|
@ -625,12 +684,12 @@ export const MessageComposer = ({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isLeadRecipient ? (
|
{showAttachmentControl ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="*/*"
|
accept={attachmentInputAccept}
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
|
|
@ -948,10 +1007,16 @@ export const MessageComposer = ({
|
||||||
<AttachmentPreviewList
|
<AttachmentPreviewList
|
||||||
attachments={draft.attachments}
|
attachments={draft.attachments}
|
||||||
onRemove={draft.removeAttachment}
|
onRemove={draft.removeAttachment}
|
||||||
error={draft.attachmentError ?? fileRestrictionError}
|
error={
|
||||||
|
draft.attachmentError ?? fileRestrictionError ?? attachmentPayloadRestrictionReason
|
||||||
|
}
|
||||||
onDismissError={draft.clearAttachmentError}
|
onDismissError={draft.clearAttachmentError}
|
||||||
disabled={attachmentsBlocked}
|
disabled={attachmentsBlocked}
|
||||||
disabledHint="File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient."
|
disabledHint={
|
||||||
|
attachmentPayloadRestrictionReason ??
|
||||||
|
attachmentRestrictionReason ??
|
||||||
|
'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ import {
|
||||||
type TaskChangeRequestOptions,
|
type TaskChangeRequestOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { normalizePathForComparison } from '@shared/utils/platformPath';
|
import { normalizePathForComparison } from '@shared/utils/platformPath';
|
||||||
import { AlertTriangle, ChevronDown, Clock, FileSearch, X } from 'lucide-react';
|
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||||
|
import { AlertTriangle, ChevronDown, Clock, FileSearch, Info, X } from 'lucide-react';
|
||||||
|
|
||||||
import { ChangesLoadingAnimation } from './ChangesLoadingAnimation';
|
import { ChangesLoadingAnimation } from './ChangesLoadingAnimation';
|
||||||
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
|
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||||
|
|
@ -75,28 +76,51 @@ const TaskChangesEmptyState = ({
|
||||||
}: {
|
}: {
|
||||||
changeSet: TaskChangeSetV2 | null;
|
changeSet: TaskChangeSetV2 | null;
|
||||||
}): React.ReactElement => {
|
}): React.ReactElement => {
|
||||||
const warnings = changeSet?.warnings ?? [];
|
const status = changeSet ? classifyTaskChangeReviewability(changeSet) : null;
|
||||||
const hasWarnings = warnings.length > 0;
|
const diagnosticMessages =
|
||||||
const Icon = hasWarnings ? AlertTriangle : FileSearch;
|
status && status.diagnostics.length > 0
|
||||||
|
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||||
|
: (changeSet?.warnings ?? []);
|
||||||
|
const uniqueMessages = [
|
||||||
|
...new Set(diagnosticMessages.filter((message) => message.trim().length > 0)),
|
||||||
|
];
|
||||||
|
const isAttention = status?.reviewability === 'attention_required';
|
||||||
|
const isDiagnosticOnly = status?.reviewability === 'diagnostic_only';
|
||||||
|
const isNoSafeDiff = isAttention || isDiagnosticOnly;
|
||||||
|
const hasDiagnosticContext = uniqueMessages.length > 0;
|
||||||
|
const Icon = isAttention ? AlertTriangle : hasDiagnosticContext ? Info : FileSearch;
|
||||||
|
const title = isDiagnosticOnly
|
||||||
|
? 'No safe diff available'
|
||||||
|
: isAttention
|
||||||
|
? 'No reviewable file changes'
|
||||||
|
: 'No file changes recorded';
|
||||||
|
const description = isNoSafeDiff
|
||||||
|
? isDiagnosticOnly
|
||||||
|
? 'The task ledger did not expose a safe file diff for this task.'
|
||||||
|
: 'The task ledger did not expose a safe file diff for this task. The diagnostics below explain why.'
|
||||||
|
: hasDiagnosticContext
|
||||||
|
? 'The task ledger has no file events for this task yet.'
|
||||||
|
: 'The task ledger has no file events for this task.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center px-6">
|
<div className="flex w-full items-center justify-center px-6">
|
||||||
<div className="max-w-xl rounded-lg border border-border bg-surface-sidebar px-5 py-4 text-center">
|
<div className="max-w-xl rounded-lg border border-border bg-surface-sidebar px-5 py-4 text-center">
|
||||||
<Icon
|
<Icon
|
||||||
className={cn('mx-auto mb-2 size-5', hasWarnings ? 'text-amber-300' : 'text-text-muted')}
|
className={cn('mx-auto mb-2 size-5', isAttention ? 'text-amber-300' : 'text-text-muted')}
|
||||||
/>
|
/>
|
||||||
<div className="text-sm font-medium text-text">
|
<div className="text-sm font-medium text-text">{title}</div>
|
||||||
{hasWarnings ? 'No reviewable file changes' : 'No file changes recorded'}
|
<p className="mt-1 text-xs leading-5 text-text-muted">{description}</p>
|
||||||
</div>
|
{uniqueMessages.length > 0 && (
|
||||||
<p className="mt-1 text-xs leading-5 text-text-muted">
|
<div
|
||||||
{hasWarnings
|
className={cn(
|
||||||
? 'The task ledger did not expose any safe file diff for this task. The diagnostics below explain why.'
|
'mt-3 space-y-1 rounded border px-3 py-2 text-left text-xs',
|
||||||
: 'The task ledger has no file events for this task.'}
|
isAttention
|
||||||
</p>
|
? 'border-amber-500/20 bg-amber-500/10 text-amber-200'
|
||||||
{warnings.length > 0 && (
|
: 'border-border bg-surface-raised text-text-muted'
|
||||||
<div className="mt-3 space-y-1 rounded border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-left text-xs text-amber-200">
|
)}
|
||||||
{warnings.map((warning, index) => (
|
>
|
||||||
<div key={`${warning}:${index}`}>{warning}</div>
|
{uniqueMessages.map((message, index) => (
|
||||||
|
<div key={`${message}:${index}`}>{message}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1179,7 +1203,7 @@ export const ChangeReviewDialog = ({
|
||||||
mode === 'task' &&
|
mode === 'task' &&
|
||||||
!!taskChangeSet &&
|
!!taskChangeSet &&
|
||||||
(taskChangeSet.provenance?.sourceKind !== 'ledger' ||
|
(taskChangeSet.provenance?.sourceKind !== 'ledger' ||
|
||||||
taskChangeSet.warnings.length > 0 ||
|
classifyTaskChangeReviewability(taskChangeSet).reviewability === 'attention_required' ||
|
||||||
taskChangeSet.scope.confidence.tier > 1);
|
taskChangeSet.scope.confidence.tier > 1);
|
||||||
|
|
||||||
// Active file for timeline (derived from scroll-spy)
|
// Active file for timeline (derived from scroll-spy)
|
||||||
|
|
|
||||||
19
src/renderer/components/team/teamChangesLoadTimeout.ts
Normal file
19
src/renderer/components/team/teamChangesLoadTimeout.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 45_000;
|
||||||
|
|
||||||
|
export function withTeamChangesLoadTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs = TEAM_CHANGES_LOAD_TIMEOUT_MS
|
||||||
|
): Promise<T> {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error('Team changes request timed out. Refresh to try again.'));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||||
|
if (timeoutId !== null) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
|
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
|
||||||
|
|
||||||
|
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
|
||||||
import {
|
import {
|
||||||
buildTeamChangeRequestPlan,
|
buildTeamChangeRequestPlan,
|
||||||
buildTeamChangesTasksFingerprint,
|
buildTeamChangesTasksFingerprint,
|
||||||
|
|
@ -136,7 +137,9 @@ export function useTeamChangesSummaries({
|
||||||
activeRequestSeqRef.current = requestSeq;
|
activeRequestSeqRef.current = requestSeq;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests);
|
const response = await withTeamChangesLoadTimeout(
|
||||||
|
api.review.getTeamTaskChangeSummaries(teamName, plan.requests)
|
||||||
|
);
|
||||||
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,12 @@ import { structuredPatch } from 'diff';
|
||||||
const taskChangesCheckInFlight = new Set<string>();
|
const taskChangesCheckInFlight = new Set<string>();
|
||||||
/** Tracks background presence revalidation for optimistic terminal summary hits */
|
/** Tracks background presence revalidation for optimistic terminal summary hits */
|
||||||
const taskChangesPresenceRevalidationInFlight = new Set<string>();
|
const taskChangesPresenceRevalidationInFlight = new Set<string>();
|
||||||
|
/** Rate-limits forced refreshes for cached needs_attention hits */
|
||||||
|
const taskChangesNeedsAttentionRevalidationTs = new Map<string, number>();
|
||||||
/** Negative results cached with timestamp — recheck after 30s */
|
/** Negative results cached with timestamp — recheck after 30s */
|
||||||
const taskChangesNegativeCache = new Map<string, number>();
|
const taskChangesNegativeCache = new Map<string, number>();
|
||||||
const NEGATIVE_CACHE_TTL = 30_000;
|
const NEGATIVE_CACHE_TTL = 30_000;
|
||||||
|
const NEEDS_ATTENTION_REVALIDATION_TTL = 30_000;
|
||||||
const TASK_CHANGE_WARM_CONCURRENCY = 4;
|
const TASK_CHANGE_WARM_CONCURRENCY = 4;
|
||||||
const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now();
|
const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now();
|
||||||
let latestAgentChangesRequestToken = 0;
|
let latestAgentChangesRequestToken = 0;
|
||||||
|
|
@ -1539,13 +1542,19 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
|
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
|
||||||
const summaryCacheable = isTaskSummaryCacheableForOptions(options);
|
const summaryCacheable = isTaskSummaryCacheableForOptions(options);
|
||||||
const cachedPresence = get().taskChangePresenceByKey[cacheKey];
|
const cachedPresence = get().taskChangePresenceByKey[cacheKey];
|
||||||
if (
|
if (summaryCacheable && cachedPresence === 'has_changes') {
|
||||||
summaryCacheable &&
|
|
||||||
(cachedPresence === 'has_changes' || cachedPresence === 'needs_attention')
|
|
||||||
) {
|
|
||||||
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
|
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (summaryCacheable && cachedPresence === 'needs_attention') {
|
||||||
|
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
|
||||||
|
const lastRevalidationMs = taskChangesNeedsAttentionRevalidationTs.get(cacheKey) ?? 0;
|
||||||
|
if (Date.now() - lastRevalidationMs >= NEEDS_ATTENTION_REVALIDATION_TTL) {
|
||||||
|
taskChangesNeedsAttentionRevalidationTs.set(cacheKey, Date.now());
|
||||||
|
void revalidateTaskChangePresence(teamName, taskId, options);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (taskChangesCheckInFlight.has(cacheKey)) return;
|
if (taskChangesCheckInFlight.has(cacheKey)) return;
|
||||||
const negativeTs = taskChangesNegativeCache.get(cacheKey);
|
const negativeTs = taskChangesNegativeCache.get(cacheKey);
|
||||||
const hasUnknownPresence = selectedTask?.changePresence === 'unknown';
|
const hasUnknownPresence = selectedTask?.changePresence === 'unknown';
|
||||||
|
|
@ -1627,14 +1636,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
summaryOnly: true,
|
summaryOnly: true,
|
||||||
});
|
});
|
||||||
const nextPresence = resolveTaskChangePresenceFromResult(data);
|
const nextPresence = resolveTaskChangePresenceFromResult(data);
|
||||||
if (nextPresence) {
|
set((s) => ({
|
||||||
set((s) => ({
|
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
|
||||||
taskChangePresenceByKey: {
|
s.taskChangePresenceByKey,
|
||||||
...s.taskChangePresenceByKey,
|
cacheKey,
|
||||||
[cacheKey]: nextPresence,
|
nextPresence
|
||||||
},
|
),
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
|
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
|
||||||
taskChangesNegativeCache.delete(cacheKey);
|
taskChangesNegativeCache.delete(cacheKey);
|
||||||
if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) {
|
if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) {
|
||||||
|
|
@ -1673,6 +1681,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
taskChangesNegativeCache.delete(key);
|
taskChangesNegativeCache.delete(key);
|
||||||
|
taskChangesNeedsAttentionRevalidationTs.delete(key);
|
||||||
}
|
}
|
||||||
return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {};
|
return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
178
src/renderer/utils/attachmentRecipientCapabilities.ts
Normal file
178
src/renderer/utils/attachmentRecipientCapabilities.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import {
|
||||||
|
resolveAgentAttachmentCapability,
|
||||||
|
type AgentAttachmentCapability,
|
||||||
|
} from '@features/agent-attachments/renderer';
|
||||||
|
import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import {
|
||||||
|
inferTeamProviderIdFromModel,
|
||||||
|
normalizeOptionalTeamProviderId,
|
||||||
|
} from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
|
import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
export interface MemberAttachmentCapabilityResult {
|
||||||
|
capability: AgentAttachmentCapability;
|
||||||
|
providerId: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberProviderId(member: ResolvedTeamMember): string {
|
||||||
|
return (
|
||||||
|
normalizeOptionalTeamProviderId(member.providerId) ??
|
||||||
|
inferTeamProviderIdFromModel(member.model) ??
|
||||||
|
'unknown'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedFileMime(mimeType: string, supported: readonly string[]): boolean {
|
||||||
|
return supported.some((candidate) =>
|
||||||
|
candidate.endsWith('/*') ? mimeType.startsWith(candidate.slice(0, -1)) : candidate === mimeType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canReceiveAnyAttachment(capability: AgentAttachmentCapability): boolean {
|
||||||
|
return capability.supportsImages || capability.supportsFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMemberAttachmentCapability(
|
||||||
|
member: ResolvedTeamMember
|
||||||
|
): MemberAttachmentCapabilityResult {
|
||||||
|
const providerId = getMemberProviderId(member);
|
||||||
|
const model = member.model ?? '';
|
||||||
|
return {
|
||||||
|
providerId,
|
||||||
|
model,
|
||||||
|
capability: resolveAgentAttachmentCapability({ providerId, model }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemberAttachmentUnavailableReason(
|
||||||
|
member: ResolvedTeamMember | null | undefined
|
||||||
|
): string | null {
|
||||||
|
if (!member) {
|
||||||
|
return 'Select a recipient before attaching files.';
|
||||||
|
}
|
||||||
|
const { capability } = resolveMemberAttachmentCapability(member);
|
||||||
|
if (canReceiveAnyAttachment(capability)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return capability.displayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentInputAcceptForMember(
|
||||||
|
member: ResolvedTeamMember | null | undefined
|
||||||
|
): string {
|
||||||
|
if (!member) {
|
||||||
|
return '*/*';
|
||||||
|
}
|
||||||
|
const { capability } = resolveMemberAttachmentCapability(member);
|
||||||
|
if (capability.supportsImages && !capability.supportsFiles) {
|
||||||
|
return 'image/png,image/jpeg,image/webp';
|
||||||
|
}
|
||||||
|
return '*/*';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAttachmentFilesForMember(input: {
|
||||||
|
member: ResolvedTeamMember | null | undefined;
|
||||||
|
files: FileList | File[];
|
||||||
|
}): string | null {
|
||||||
|
const member = input.member;
|
||||||
|
if (!member) {
|
||||||
|
return 'Select a recipient before attaching files.';
|
||||||
|
}
|
||||||
|
const files = Array.from(input.files);
|
||||||
|
if (files.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { capability } = resolveMemberAttachmentCapability(member);
|
||||||
|
if (!canReceiveAnyAttachment(capability)) {
|
||||||
|
return capability.displayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const category = categorizeFile(file);
|
||||||
|
if (category === 'unsupported') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (category === 'image') {
|
||||||
|
if (!capability.supportsImages) {
|
||||||
|
return capability.displayText;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!capability.supportsFiles) {
|
||||||
|
return capability.filesDisplayText;
|
||||||
|
}
|
||||||
|
const mimeType = getEffectiveMimeType(file);
|
||||||
|
if (!isSupportedFileMime(mimeType, capability.supportedFileMimeTypes)) {
|
||||||
|
return 'This file type is not supported by the selected model.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAttachmentPayloadsForMember(input: {
|
||||||
|
member: ResolvedTeamMember | null | undefined;
|
||||||
|
attachments: readonly AttachmentPayload[];
|
||||||
|
}): string | null {
|
||||||
|
const member = input.member;
|
||||||
|
if (!member || input.attachments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { capability } = resolveMemberAttachmentCapability(member);
|
||||||
|
if (!canReceiveAnyAttachment(capability)) {
|
||||||
|
return capability.displayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageCount = 0;
|
||||||
|
let fileCount = 0;
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const attachment of input.attachments) {
|
||||||
|
totalBytes += attachment.size;
|
||||||
|
if (isImageMime(attachment.mimeType)) {
|
||||||
|
imageCount += 1;
|
||||||
|
if (!capability.supportsImages) {
|
||||||
|
return capability.displayText;
|
||||||
|
}
|
||||||
|
if (attachment.size > capability.maxBytesPerImage) {
|
||||||
|
return 'Image is too large for the selected model.';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCount += 1;
|
||||||
|
if (!capability.supportsFiles) {
|
||||||
|
return capability.filesDisplayText;
|
||||||
|
}
|
||||||
|
if (!isSupportedFileMime(attachment.mimeType, capability.supportedFileMimeTypes)) {
|
||||||
|
return 'This file type is not supported by the selected model.';
|
||||||
|
}
|
||||||
|
if (attachment.size > capability.maxBytesPerFile) {
|
||||||
|
return 'File is too large for the selected model.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageCount > capability.maxImages) {
|
||||||
|
return `Maximum ${capability.maxImages} image attachments for this model.`;
|
||||||
|
}
|
||||||
|
if (fileCount > capability.maxFiles) {
|
||||||
|
return `Maximum ${capability.maxFiles} file attachments for this model.`;
|
||||||
|
}
|
||||||
|
if (totalBytes > capability.maxBytesTotal) {
|
||||||
|
return 'Attachments exceed the selected model size limit.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMemberShowAttachmentControl(
|
||||||
|
member: ResolvedTeamMember | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!member) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const providerId = getMemberProviderId(member);
|
||||||
|
return isLeadMember(member) || providerId === 'opencode';
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,17 @@ const PROOF_WARNING =
|
||||||
'OpenCode reply could not be verified. Message was saved to inbox, but no visible reply or task progress proof was found.';
|
'OpenCode reply could not be verified. Message was saved to inbox, but no visible reply or task progress proof was found.';
|
||||||
const FAILED_WARNING =
|
const FAILED_WARNING =
|
||||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
||||||
|
const ATTACHMENT_FAILED_WARNING =
|
||||||
|
'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment.';
|
||||||
|
|
||||||
|
function isOpenCodeAttachmentDeliveryFailureReason(reason: string | null | undefined): boolean {
|
||||||
|
const normalized = reason?.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === 'opencode_attachment_delivery_prepare_failed' ||
|
||||||
|
normalized?.startsWith('attachment_') === true ||
|
||||||
|
normalized?.startsWith('opencode_attachment_delivery_prepare_failed:') === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | undefined): string {
|
function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | undefined): string {
|
||||||
const normalized = reason?.trim();
|
const normalized = reason?.trim();
|
||||||
|
|
@ -69,6 +80,33 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
|
||||||
if (normalizedLower === 'non_visible_tool_without_task_progress') {
|
if (normalizedLower === 'non_visible_tool_without_task_progress') {
|
||||||
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
|
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
|
||||||
}
|
}
|
||||||
|
if (normalizedLower === 'attachment_model_unsupported') {
|
||||||
|
return 'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.';
|
||||||
|
}
|
||||||
|
if (normalizedLower === 'attachment_type_unsupported') {
|
||||||
|
return 'This OpenCode model cannot receive this attachment type. Remove the attachment or choose a supported image model.';
|
||||||
|
}
|
||||||
|
if (normalizedLower === 'attachment_too_large') {
|
||||||
|
return 'The attachment is too large for live OpenCode delivery. Reduce the image size or remove the attachment.';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalizedLower === 'attachment_artifact_missing' ||
|
||||||
|
normalizedLower === 'attachment_artifact_path_unsafe'
|
||||||
|
) {
|
||||||
|
return 'The attachment file is not available for live OpenCode delivery. Reattach the file and try again.';
|
||||||
|
}
|
||||||
|
if (normalizedLower === 'attachment_optimization_failed') {
|
||||||
|
return 'The attachment could not be optimized for live OpenCode delivery. Try a smaller image or remove the attachment.';
|
||||||
|
}
|
||||||
|
if (normalizedLower === 'attachment_provider_rejected') {
|
||||||
|
return 'The OpenCode provider rejected the attachment. Choose a different model or remove the attachment.';
|
||||||
|
}
|
||||||
|
if (normalizedLower === 'attachment_runtime_transport_failed') {
|
||||||
|
return 'OpenCode could not transport the attachment to the runtime. Try again or remove the attachment.';
|
||||||
|
}
|
||||||
|
if (normalizedLower.startsWith('opencode_attachment_delivery_prepare_failed:')) {
|
||||||
|
return normalized.slice('opencode_attachment_delivery_prepare_failed:'.length).trim();
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,12 +132,16 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
||||||
}
|
}
|
||||||
|
|
||||||
const userVisibleMessage = runtimeDelivery.userVisibleImpact?.message?.trim();
|
const userVisibleMessage = runtimeDelivery.userVisibleImpact?.message?.trim();
|
||||||
const failureReason =
|
const candidateFailureReason =
|
||||||
isFailed || isWarning
|
userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0];
|
||||||
? formatOpenCodeRuntimeDeliveryFailureReason(
|
const mappedFailureReason =
|
||||||
userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0]
|
isFailed || isWarning ? formatOpenCodeRuntimeDeliveryFailureReason(candidateFailureReason) : '';
|
||||||
)
|
const failureReason = mappedFailureReason || (isFailed || isWarning ? userVisibleMessage : '');
|
||||||
: '';
|
const isAttachmentFailure =
|
||||||
|
isFailed &&
|
||||||
|
(isOpenCodeAttachmentDeliveryFailureReason(runtimeDelivery.reason) ||
|
||||||
|
isOpenCodeAttachmentDeliveryFailureReason(runtimeDelivery.diagnostics?.[0]) ||
|
||||||
|
isOpenCodeAttachmentDeliveryFailureReason(candidateFailureReason));
|
||||||
const statusMessageId = runtimeDelivery.queuedBehindMessageId ?? result.messageId;
|
const statusMessageId = runtimeDelivery.queuedBehindMessageId ?? result.messageId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -108,11 +150,15 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
||||||
? `${PROOF_WARNING} Reason: ${failureReason}`
|
? `${PROOF_WARNING} Reason: ${failureReason}`
|
||||||
: isWarning
|
: isWarning
|
||||||
? PROOF_WARNING
|
? PROOF_WARNING
|
||||||
: isFailed && failureReason
|
: isAttachmentFailure && failureReason
|
||||||
? `${FAILED_WARNING} Reason: ${failureReason}`
|
? `${ATTACHMENT_FAILED_WARNING} Reason: ${failureReason}`
|
||||||
: isFailed
|
: isAttachmentFailure
|
||||||
? FAILED_WARNING
|
? ATTACHMENT_FAILED_WARNING
|
||||||
: PENDING_WARNING,
|
: isFailed && failureReason
|
||||||
|
? `${FAILED_WARNING} Reason: ${failureReason}`
|
||||||
|
: isFailed
|
||||||
|
? FAILED_WARNING
|
||||||
|
: PENDING_WARNING,
|
||||||
debugDetails: {
|
debugDetails: {
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
statusMessageId,
|
statusMessageId,
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,65 @@ export interface TaskChangeSet {
|
||||||
computedAt: string;
|
computedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TASK_CHANGE_DIAGNOSTIC_CODES = [
|
||||||
|
'multi_scope_no_safe_diff',
|
||||||
|
'active_task_no_edits_yet',
|
||||||
|
'summary_timeout',
|
||||||
|
'summary_reconstructed',
|
||||||
|
'journal_unavailable',
|
||||||
|
'ledger_integrity_recovered',
|
||||||
|
'ledger_integrity_partial',
|
||||||
|
'ledger_freshness_mismatch',
|
||||||
|
'diff_stat_partial',
|
||||||
|
'tool_failed_after_edit',
|
||||||
|
'tool_killed_after_edit',
|
||||||
|
'unsafe_or_untrusted_evidence',
|
||||||
|
'legacy_warning',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TaskChangeDiagnosticCode = (typeof TASK_CHANGE_DIAGNOSTIC_CODES)[number];
|
||||||
|
|
||||||
|
export type TaskChangeDiagnosticSeverity = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface TaskChangeReviewDiagnostic {
|
||||||
|
code: TaskChangeDiagnosticCode;
|
||||||
|
severity: TaskChangeDiagnosticSeverity;
|
||||||
|
reviewBlocking: boolean;
|
||||||
|
message: string;
|
||||||
|
source?: 'ledger' | 'legacy' | 'summary' | 'runtime';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskChangeReviewability =
|
||||||
|
| 'reviewable'
|
||||||
|
| 'attention_required'
|
||||||
|
| 'diagnostic_only'
|
||||||
|
| 'none'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export type TaskChangeReviewAction =
|
||||||
|
| 'review_diff'
|
||||||
|
| 'inspect_diagnostics'
|
||||||
|
| 'wait_or_refresh'
|
||||||
|
| 'nothing';
|
||||||
|
|
||||||
|
export type TaskChangeReviewReasonCode =
|
||||||
|
| 'files_changed'
|
||||||
|
| 'files_changed_with_non_blocking_diagnostics'
|
||||||
|
| 'diagnostic_only'
|
||||||
|
| 'confirmed_no_changes'
|
||||||
|
| 'pending_no_edits_yet'
|
||||||
|
| 'blocking_diagnostics'
|
||||||
|
| 'low_confidence';
|
||||||
|
|
||||||
|
export interface TaskChangeReviewabilityStatus {
|
||||||
|
reviewability: TaskChangeReviewability;
|
||||||
|
reasonCode: TaskChangeReviewReasonCode;
|
||||||
|
userAction: TaskChangeReviewAction;
|
||||||
|
severity: 'success' | 'warning' | 'info' | 'none';
|
||||||
|
message: string;
|
||||||
|
diagnostics: TaskChangeReviewDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Краткая статистика для badge */
|
/** Краткая статистика для badge */
|
||||||
export interface ChangeStats {
|
export interface ChangeStats {
|
||||||
linesAdded: number;
|
linesAdded: number;
|
||||||
|
|
@ -287,6 +346,7 @@ export interface TaskBoundariesResult {
|
||||||
export interface TaskChangeSetV2 extends TaskChangeSet {
|
export interface TaskChangeSetV2 extends TaskChangeSet {
|
||||||
scope: TaskChangeScope;
|
scope: TaskChangeScope;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
reviewDiagnostics?: TaskChangeReviewDiagnostic[];
|
||||||
diffStatCompleteness?: 'complete' | 'partial';
|
diffStatCompleteness?: 'complete' | 'partial';
|
||||||
provenance?: TaskChangeProvenance;
|
provenance?: TaskChangeProvenance;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
257
src/shared/utils/__tests__/taskChangeReviewability.test.ts
Normal file
257
src/shared/utils/__tests__/taskChangeReviewability.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveTaskChangePresenceFromResult } from '../taskChangePresence';
|
||||||
|
import {
|
||||||
|
classifyTaskChangeReviewability,
|
||||||
|
EMPTY_INTERVAL_NO_EDITS_WARNING,
|
||||||
|
} from '../taskChangeReviewability';
|
||||||
|
|
||||||
|
import type { TaskChangeSetV2 } from '../../types';
|
||||||
|
|
||||||
|
function changeSet(overrides: Partial<TaskChangeSetV2> = {}): TaskChangeSetV2 {
|
||||||
|
return {
|
||||||
|
teamName: 'team-a',
|
||||||
|
taskId: 'task-a',
|
||||||
|
files: [],
|
||||||
|
totalFiles: 0,
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
confidence: 'high',
|
||||||
|
computedAt: '2026-05-09T12:00:00.000Z',
|
||||||
|
scope: {
|
||||||
|
taskId: 'task-a',
|
||||||
|
memberName: 'alice',
|
||||||
|
startLine: 0,
|
||||||
|
endLine: 0,
|
||||||
|
startTimestamp: '2026-05-09T11:00:00.000Z',
|
||||||
|
endTimestamp: '2026-05-09T11:10:00.000Z',
|
||||||
|
toolUseIds: [],
|
||||||
|
filePaths: [],
|
||||||
|
confidence: { tier: 1, label: 'high', reason: 'test' },
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('taskChangeReviewability', () => {
|
||||||
|
it('treats changed files with non-blocking multi-scope diagnostics as reviewable', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filePath: '/repo/src/file.ts',
|
||||||
|
relativePath: 'src/file.ts',
|
||||||
|
snippets: [],
|
||||||
|
linesAdded: 1,
|
||||||
|
linesRemoved: 0,
|
||||||
|
isNewFile: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalFiles: 1,
|
||||||
|
totalLinesAdded: 1,
|
||||||
|
warnings: [
|
||||||
|
'Task change ledger skipped attribution because multiple task scopes were active.',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(classifyTaskChangeReviewability(result).reviewability).toBe('reviewable');
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBe('has_changes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies warning-only multi-scope notices as diagnostic-only', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
warnings: [
|
||||||
|
'Task change ledger skipped attribution because multiple task scopes were active.',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(classifyTaskChangeReviewability(result)).toMatchObject({
|
||||||
|
reviewability: 'diagnostic_only',
|
||||||
|
reasonCode: 'diagnostic_only',
|
||||||
|
userAction: 'inspect_diagnostics',
|
||||||
|
});
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for unclassified warning-only summaries', () => {
|
||||||
|
const result = changeSet({ warnings: ['Unexpected ledger warning.'] });
|
||||||
|
|
||||||
|
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps active no-edit intervals unknown instead of needs attention', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING],
|
||||||
|
scope: {
|
||||||
|
...changeSet().scope,
|
||||||
|
startTimestamp: '2026-05-09T11:00:00.000Z',
|
||||||
|
endTimestamp: '',
|
||||||
|
toolUseIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(classifyTaskChangeReviewability(result).reviewability).toBe('unknown');
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps active no-edit intervals fail-closed when blocking diagnostics are present', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING, 'Task changes scan timed out.'],
|
||||||
|
scope: {
|
||||||
|
...changeSet().scope,
|
||||||
|
startTimestamp: '2026-05-09T11:00:00.000Z',
|
||||||
|
endTimestamp: '',
|
||||||
|
toolUseIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks partial ledger evidence as attention required', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
provenance: {
|
||||||
|
sourceKind: 'ledger',
|
||||||
|
sourceFingerprint: 'fingerprint',
|
||||||
|
integrity: 'partial',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates recovered ledger diagnostics from typed diagnostics and provenance', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
reviewDiagnostics: [
|
||||||
|
{
|
||||||
|
code: 'ledger_integrity_recovered',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||||
|
source: 'ledger',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
provenance: {
|
||||||
|
sourceKind: 'ledger',
|
||||||
|
sourceFingerprint: 'fingerprint',
|
||||||
|
integrity: 'recovered',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = classifyTaskChangeReviewability(result);
|
||||||
|
|
||||||
|
expect(status.reviewability).toBe('attention_required');
|
||||||
|
expect(status.diagnostics).toHaveLength(1);
|
||||||
|
expect(status.diagnostics[0]?.code).toBe('ledger_integrity_recovered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not downgrade typed blocking diagnostics when legacy warnings duplicate them', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
reviewDiagnostics: [
|
||||||
|
{
|
||||||
|
code: 'multi_scope_no_safe_diff',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message:
|
||||||
|
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
|
||||||
|
source: 'ledger',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [
|
||||||
|
'Task change ledger skipped attribution because multiple task scopes were active.',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = classifyTaskChangeReviewability(result);
|
||||||
|
|
||||||
|
expect(status.reviewability).toBe('attention_required');
|
||||||
|
expect(status.diagnostics).toHaveLength(1);
|
||||||
|
expect(status.diagnostics[0]?.reviewBlocking).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades duplicate diagnostics when legacy warnings are more strict', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
reviewDiagnostics: [
|
||||||
|
{
|
||||||
|
code: 'legacy_warning',
|
||||||
|
severity: 'info',
|
||||||
|
reviewBlocking: false,
|
||||||
|
message: 'Unexpected ledger warning.',
|
||||||
|
source: 'summary',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: ['Unexpected ledger warning.'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = classifyTaskChangeReviewability(result);
|
||||||
|
|
||||||
|
expect(status.reviewability).toBe('attention_required');
|
||||||
|
expect(status.diagnostics).toHaveLength(1);
|
||||||
|
expect(status.diagnostics[0]).toMatchObject({
|
||||||
|
code: 'legacy_warning',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
source: 'legacy',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when reported files are missing safe review details', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
totalFiles: 2,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filePath: '/repo/src/file.ts',
|
||||||
|
relativePath: 'src/file.ts',
|
||||||
|
snippets: [],
|
||||||
|
linesAdded: 1,
|
||||||
|
linesRemoved: 0,
|
||||||
|
isNewFile: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalLinesAdded: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = classifyTaskChangeReviewability(result);
|
||||||
|
|
||||||
|
expect(status).toMatchObject({
|
||||||
|
reviewability: 'attention_required',
|
||||||
|
reasonCode: 'blocking_diagnostics',
|
||||||
|
userAction: 'review_diff',
|
||||||
|
});
|
||||||
|
expect(status.diagnostics).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'unsafe_or_untrusted_evidence',
|
||||||
|
reviewBlocking: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates malformed cached scope and diagnostic shapes', () => {
|
||||||
|
const result = changeSet({
|
||||||
|
totalFiles: 'not-a-number' as unknown as number,
|
||||||
|
reviewDiagnostics: {} as unknown as TaskChangeSetV2['reviewDiagnostics'],
|
||||||
|
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING],
|
||||||
|
scope: {
|
||||||
|
taskId: 'task-a',
|
||||||
|
memberName: 'alice',
|
||||||
|
startTimestamp: '2026-05-09T11:00:00.000Z',
|
||||||
|
endTimestamp: '',
|
||||||
|
confidence: { tier: 2, label: 'medium', reason: 'legacy cache fixture' },
|
||||||
|
} as unknown as TaskChangeSetV2['scope'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(classifyTaskChangeReviewability(result).reviewability).toBe('unknown');
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirms empty high-confidence summaries as no changes', () => {
|
||||||
|
const result = changeSet();
|
||||||
|
|
||||||
|
expect(classifyTaskChangeReviewability(result).reviewability).toBe('none');
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBe('no_changes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,35 +1,21 @@
|
||||||
|
import { classifyTaskChangeReviewability } from './taskChangeReviewability';
|
||||||
|
|
||||||
import type { TaskChangePresenceState, TaskChangeSetV2 } from '../types';
|
import type { TaskChangePresenceState, TaskChangeSetV2 } from '../types';
|
||||||
|
|
||||||
const EMPTY_INTERVAL_NO_EDITS_WARNING = 'No file edits found within persisted workIntervals.';
|
|
||||||
|
|
||||||
function isBenignActiveIntervalWithoutFileEdits(
|
|
||||||
data: Pick<TaskChangeSetV2, 'files' | 'warnings' | 'scope'>
|
|
||||||
): boolean {
|
|
||||||
if (data.files.length > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.warnings.length !== 1 || data.warnings[0] !== EMPTY_INTERVAL_NO_EDITS_WARNING) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Boolean(data.scope.startTimestamp) && !data.scope.endTimestamp && data.scope.toolUseIds.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveTaskChangePresenceFromResult(
|
export function resolveTaskChangePresenceFromResult(
|
||||||
data: Pick<TaskChangeSetV2, 'files' | 'confidence' | 'warnings' | 'scope'>
|
data: Pick<TaskChangeSetV2, 'files' | 'totalFiles' | 'confidence' | 'warnings' | 'scope'> &
|
||||||
|
Partial<Pick<TaskChangeSetV2, 'diffStatCompleteness' | 'provenance' | 'reviewDiagnostics'>>
|
||||||
): Exclude<TaskChangePresenceState, 'unknown'> | null {
|
): Exclude<TaskChangePresenceState, 'unknown'> | null {
|
||||||
if (data.files.length > 0) {
|
const status = classifyTaskChangeReviewability(data);
|
||||||
return 'has_changes';
|
switch (status.reviewability) {
|
||||||
|
case 'reviewable':
|
||||||
|
return 'has_changes';
|
||||||
|
case 'attention_required':
|
||||||
|
return 'needs_attention';
|
||||||
|
case 'none':
|
||||||
|
return 'no_changes';
|
||||||
|
case 'diagnostic_only':
|
||||||
|
case 'unknown':
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBenignActiveIntervalWithoutFileEdits(data)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((data.warnings?.length ?? 0) > 0) {
|
|
||||||
return 'needs_attention';
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
391
src/shared/utils/taskChangeReviewability.ts
Normal file
391
src/shared/utils/taskChangeReviewability.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '../types';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TaskChangeDiagnosticCode,
|
||||||
|
TaskChangeDiagnosticSeverity,
|
||||||
|
TaskChangeReviewabilityStatus,
|
||||||
|
TaskChangeReviewDiagnostic,
|
||||||
|
TaskChangeSetV2,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const EMPTY_INTERVAL_NO_EDITS_WARNING =
|
||||||
|
'No file edits found within persisted workIntervals.';
|
||||||
|
|
||||||
|
const MULTI_SCOPE_MESSAGES = [
|
||||||
|
'Task change ledger skipped attribution because multiple task scopes were active.',
|
||||||
|
'Ledger skipped attribution because multiple task scopes were active.',
|
||||||
|
] as const;
|
||||||
|
const TASK_CHANGE_DIAGNOSTIC_CODE_SET = new Set<string>(TASK_CHANGE_DIAGNOSTIC_CODES);
|
||||||
|
|
||||||
|
type ReviewabilityInput = Pick<
|
||||||
|
TaskChangeSetV2,
|
||||||
|
'files' | 'totalFiles' | 'confidence' | 'warnings' | 'scope'
|
||||||
|
> &
|
||||||
|
Partial<Pick<TaskChangeSetV2, 'diffStatCompleteness' | 'provenance' | 'reviewDiagnostics'>>;
|
||||||
|
|
||||||
|
interface DiagnosticTemplate {
|
||||||
|
code: TaskChangeDiagnosticCode;
|
||||||
|
severity: TaskChangeDiagnosticSeverity;
|
||||||
|
reviewBlocking: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateForLegacyWarning(warning: string): DiagnosticTemplate {
|
||||||
|
const trimmed = warning.trim();
|
||||||
|
const normalized = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
if (MULTI_SCOPE_MESSAGES.some((message) => message.toLowerCase() === normalized)) {
|
||||||
|
return {
|
||||||
|
code: 'multi_scope_no_safe_diff',
|
||||||
|
severity: 'info',
|
||||||
|
reviewBlocking: false,
|
||||||
|
message:
|
||||||
|
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === EMPTY_INTERVAL_NO_EDITS_WARNING.toLowerCase()) {
|
||||||
|
return {
|
||||||
|
code: 'active_task_no_edits_yet',
|
||||||
|
severity: 'info',
|
||||||
|
reviewBlocking: false,
|
||||||
|
message: 'No file edits have been observed in the active task interval yet.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('timed out')) {
|
||||||
|
return {
|
||||||
|
code: 'summary_timeout',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The changes scan timed out before it could finish.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('fell back to journal reconstruction')) {
|
||||||
|
return {
|
||||||
|
code: 'summary_reconstructed',
|
||||||
|
severity: 'info',
|
||||||
|
reviewBlocking: false,
|
||||||
|
message: 'The change summary was reconstructed from the task-change journal.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('journal was unavailable')) {
|
||||||
|
return {
|
||||||
|
code: 'journal_unavailable',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'Detailed ledger entries were unavailable for this task.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('recovered from malformed journal lines')) {
|
||||||
|
return {
|
||||||
|
code: 'ledger_integrity_recovered',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('freshness did not match') ||
|
||||||
|
normalized.includes('partial') ||
|
||||||
|
normalized.includes('integrity')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
code: 'ledger_integrity_partial',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger may be incomplete or stale.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('tool ') && normalized.includes(' failed after changing files')) {
|
||||||
|
return {
|
||||||
|
code: 'tool_failed_after_edit',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'A tool failed after changing files.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.startsWith('background tool ') &&
|
||||||
|
normalized.includes(' was killed after changing files')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
code: 'tool_killed_after_edit',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'A background tool was killed after changing files.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 'legacy_warning',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: trimmed || 'The change summary reported an unclassified warning.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskChangeDiagnosticFromWarning(
|
||||||
|
warning: string,
|
||||||
|
source: TaskChangeReviewDiagnostic['source'] = 'legacy'
|
||||||
|
): TaskChangeReviewDiagnostic {
|
||||||
|
const template = templateForLegacyWarning(warning);
|
||||||
|
return { ...template, source };
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticKey(diagnostic: TaskChangeReviewDiagnostic): string {
|
||||||
|
return `${diagnostic.code}:${diagnostic.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticSeverityRank(severity: TaskChangeDiagnosticSeverity): number {
|
||||||
|
switch (severity) {
|
||||||
|
case 'error':
|
||||||
|
return 3;
|
||||||
|
case 'warning':
|
||||||
|
return 2;
|
||||||
|
case 'info':
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeTaskChangeReviewDiagnostics(
|
||||||
|
existing: TaskChangeReviewDiagnostic,
|
||||||
|
incoming: TaskChangeReviewDiagnostic
|
||||||
|
): TaskChangeReviewDiagnostic {
|
||||||
|
if (
|
||||||
|
incoming.reviewBlocking &&
|
||||||
|
(!existing.reviewBlocking ||
|
||||||
|
diagnosticSeverityRank(incoming.severity) > diagnosticSeverityRank(existing.severity))
|
||||||
|
) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing.reviewBlocking === incoming.reviewBlocking &&
|
||||||
|
diagnosticSeverityRank(incoming.severity) > diagnosticSeverityRank(existing.severity)
|
||||||
|
) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDiagnostic(
|
||||||
|
diagnostics: Map<string, TaskChangeReviewDiagnostic>,
|
||||||
|
diagnostic: TaskChangeReviewDiagnostic
|
||||||
|
): void {
|
||||||
|
const key = diagnosticKey(diagnostic);
|
||||||
|
const existing = diagnostics.get(key);
|
||||||
|
if (existing) {
|
||||||
|
diagnostics.set(key, mergeTaskChangeReviewDiagnostics(existing, diagnostic));
|
||||||
|
} else {
|
||||||
|
diagnostics.set(key, diagnostic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputFiles(input: ReviewabilityInput): TaskChangeSetV2['files'] {
|
||||||
|
return Array.isArray(input.files) ? input.files : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputWarnings(input: ReviewabilityInput): string[] {
|
||||||
|
return Array.isArray(input.warnings)
|
||||||
|
? input.warnings.filter((warning): warning is string => typeof warning === 'string')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputReviewDiagnostics(input: ReviewabilityInput): TaskChangeReviewDiagnostic[] {
|
||||||
|
if (!Array.isArray(input.reviewDiagnostics)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return input.reviewDiagnostics.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => {
|
||||||
|
if (!diagnostic || typeof diagnostic !== 'object' || Array.isArray(diagnostic)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = diagnostic as Partial<TaskChangeReviewDiagnostic>;
|
||||||
|
return (
|
||||||
|
typeof candidate.code === 'string' &&
|
||||||
|
TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(candidate.code) &&
|
||||||
|
(candidate.severity === 'info' ||
|
||||||
|
candidate.severity === 'warning' ||
|
||||||
|
candidate.severity === 'error') &&
|
||||||
|
typeof candidate.reviewBlocking === 'boolean' &&
|
||||||
|
typeof candidate.message === 'string'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputToolUseIds(input: ReviewabilityInput): string[] {
|
||||||
|
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
|
||||||
|
return Array.isArray(scope?.toolUseIds) ? scope.toolUseIds : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputStartTimestamp(input: ReviewabilityInput): string {
|
||||||
|
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
|
||||||
|
return typeof scope?.startTimestamp === 'string' ? scope.startTimestamp : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputEndTimestamp(input: ReviewabilityInput): string {
|
||||||
|
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
|
||||||
|
return typeof scope?.endTimestamp === 'string' ? scope.endTimestamp : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputTotalFiles(input: ReviewabilityInput, fileCount: number): number {
|
||||||
|
const totalFiles = Number(input.totalFiles);
|
||||||
|
if (!Number.isFinite(totalFiles) || totalFiles < 0) {
|
||||||
|
return fileCount;
|
||||||
|
}
|
||||||
|
return Math.trunc(totalFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDiagnostics(input: ReviewabilityInput): TaskChangeReviewDiagnostic[] {
|
||||||
|
const diagnostics = new Map<string, TaskChangeReviewDiagnostic>();
|
||||||
|
|
||||||
|
for (const diagnostic of getInputReviewDiagnostics(input)) {
|
||||||
|
addDiagnostic(diagnostics, diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const warning of getInputWarnings(input)) {
|
||||||
|
const diagnostic = createTaskChangeDiagnosticFromWarning(warning);
|
||||||
|
addDiagnostic(diagnostics, diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.diffStatCompleteness === 'partial') {
|
||||||
|
const diagnostic: TaskChangeReviewDiagnostic = {
|
||||||
|
code: 'diff_stat_partial',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'Some file change statistics are incomplete.',
|
||||||
|
source: 'summary',
|
||||||
|
};
|
||||||
|
addDiagnostic(diagnostics, diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.provenance?.integrity === 'partial') {
|
||||||
|
const diagnostic: TaskChangeReviewDiagnostic = {
|
||||||
|
code: 'ledger_integrity_partial',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger is partially available.',
|
||||||
|
source: 'ledger',
|
||||||
|
};
|
||||||
|
addDiagnostic(diagnostics, diagnostic);
|
||||||
|
} else if (input.provenance?.integrity === 'recovered') {
|
||||||
|
const diagnostic: TaskChangeReviewDiagnostic = {
|
||||||
|
code: 'ledger_integrity_recovered',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||||
|
source: 'ledger',
|
||||||
|
};
|
||||||
|
addDiagnostic(diagnostics, diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = getInputFiles(input).length;
|
||||||
|
const totalFiles = getInputTotalFiles(input, fileCount);
|
||||||
|
if (totalFiles > fileCount) {
|
||||||
|
const missingFileCount = totalFiles - fileCount;
|
||||||
|
const diagnostic: TaskChangeReviewDiagnostic = {
|
||||||
|
code: 'unsafe_or_untrusted_evidence',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message:
|
||||||
|
missingFileCount === 1
|
||||||
|
? 'The change summary reported one file without safe review details.'
|
||||||
|
: `The change summary reported ${missingFileCount} files without safe review details.`,
|
||||||
|
source: 'summary',
|
||||||
|
};
|
||||||
|
addDiagnostic(diagnostics, diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...diagnostics.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveIntervalWithoutFileEdits(
|
||||||
|
input: ReviewabilityInput,
|
||||||
|
diagnostics: TaskChangeReviewDiagnostic[]
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
getInputFiles(input).length === 0 &&
|
||||||
|
diagnostics.some((diagnostic) => diagnostic.code === 'active_task_no_edits_yet') &&
|
||||||
|
Boolean(getInputStartTimestamp(input)) &&
|
||||||
|
!getInputEndTimestamp(input) &&
|
||||||
|
getInputToolUseIds(input).length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyTaskChangeReviewability(
|
||||||
|
input: ReviewabilityInput
|
||||||
|
): TaskChangeReviewabilityStatus {
|
||||||
|
const diagnostics = collectDiagnostics(input);
|
||||||
|
const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.reviewBlocking);
|
||||||
|
const hasFiles = getInputFiles(input).length > 0;
|
||||||
|
|
||||||
|
if (blockingDiagnostics.length > 0) {
|
||||||
|
return {
|
||||||
|
reviewability: 'attention_required',
|
||||||
|
reasonCode: 'blocking_diagnostics',
|
||||||
|
userAction: hasFiles ? 'review_diff' : 'inspect_diagnostics',
|
||||||
|
severity: 'warning',
|
||||||
|
message: hasFiles ? 'Changes may be incomplete.' : 'Changes need attention.',
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActiveIntervalWithoutFileEdits(input, diagnostics)) {
|
||||||
|
return {
|
||||||
|
reviewability: 'unknown',
|
||||||
|
reasonCode: 'pending_no_edits_yet',
|
||||||
|
userAction: 'wait_or_refresh',
|
||||||
|
severity: 'none',
|
||||||
|
message: 'No file edits have been observed yet.',
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFiles) {
|
||||||
|
return {
|
||||||
|
reviewability: 'reviewable',
|
||||||
|
reasonCode:
|
||||||
|
diagnostics.length > 0 ? 'files_changed_with_non_blocking_diagnostics' : 'files_changed',
|
||||||
|
userAction: 'review_diff',
|
||||||
|
severity: 'success',
|
||||||
|
message: 'Reviewable file changes are available.',
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return {
|
||||||
|
reviewability: 'diagnostic_only',
|
||||||
|
reasonCode: 'diagnostic_only',
|
||||||
|
userAction: 'inspect_diagnostics',
|
||||||
|
severity: 'info',
|
||||||
|
message: 'No safe diff is available for this task.',
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.confidence === 'high' || input.confidence === 'medium') {
|
||||||
|
return {
|
||||||
|
reviewability: 'none',
|
||||||
|
reasonCode: 'confirmed_no_changes',
|
||||||
|
userAction: 'nothing',
|
||||||
|
severity: 'none',
|
||||||
|
message: 'No reviewable file changes were found.',
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reviewability: 'unknown',
|
||||||
|
reasonCode: 'low_confidence',
|
||||||
|
userAction: 'wait_or_refresh',
|
||||||
|
severity: 'none',
|
||||||
|
message: 'The change summary is not confident enough yet.',
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ module.exports = {
|
||||||
content: [
|
content: [
|
||||||
'./src/renderer/index.html',
|
'./src/renderer/index.html',
|
||||||
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'./src/features/**/*.{js,ts,jsx,tsx}',
|
||||||
'./src/shared/**/*.{js,ts,jsx,tsx}',
|
'./src/shared/**/*.{js,ts,jsx,tsx}',
|
||||||
'./packages/agent-graph/src/**/*.{js,ts,jsx,tsx}'
|
'./packages/agent-graph/src/**/*.{js,ts,jsx,tsx}'
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -822,6 +822,9 @@ describe('MemberWorkSync use cases', () => {
|
||||||
const inbox = new InMemoryInboxNudge();
|
const inbox = new InMemoryInboxNudge();
|
||||||
const deliveryCalls: Array<Parameters<MemberWorkSyncReviewPickupDeliveryPort['deliver']>[0]> =
|
const deliveryCalls: Array<Parameters<MemberWorkSyncReviewPickupDeliveryPort['deliver']>[0]> =
|
||||||
[];
|
[];
|
||||||
|
const busyCalls: Parameters<
|
||||||
|
NonNullable<MemberWorkSyncUseCaseDeps['busySignal']>['isBusy']
|
||||||
|
>[0][] = [];
|
||||||
const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = {
|
const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = {
|
||||||
canDeliver: async () => ({ ok: true }),
|
canDeliver: async () => ({ ok: true }),
|
||||||
deliver: async (input) => {
|
deliver: async (input) => {
|
||||||
|
|
@ -840,6 +843,12 @@ describe('MemberWorkSync use cases', () => {
|
||||||
outboxStore: outbox,
|
outboxStore: outbox,
|
||||||
inboxNudge: inbox,
|
inboxNudge: inbox,
|
||||||
reviewPickupDelivery,
|
reviewPickupDelivery,
|
||||||
|
busySignal: {
|
||||||
|
isBusy: (input) => {
|
||||||
|
busyCalls.push(input);
|
||||||
|
return Promise.resolve({ busy: false });
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await new MemberWorkSyncReconciler(deps).execute(
|
await new MemberWorkSyncReconciler(deps).execute(
|
||||||
|
|
@ -853,6 +862,15 @@ describe('MemberWorkSync use cases', () => {
|
||||||
|
|
||||||
expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 });
|
expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 });
|
||||||
expect(inbox.inserted).toHaveLength(1);
|
expect(inbox.inserted).toHaveLength(1);
|
||||||
|
expect(busyCalls).toEqual([
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
nowIso: '2026-04-29T00:00:00.000Z',
|
||||||
|
workSyncIntent: 'review_pickup',
|
||||||
|
taskRefs: [{ taskId: 'task-review', displayId: '22222222', teamName: 'team-a' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(deliveryCalls).toHaveLength(1);
|
expect(deliveryCalls).toHaveLength(1);
|
||||||
expect(deliveryCalls[0]).toMatchObject({
|
expect(deliveryCalls[0]).toMatchObject({
|
||||||
messageId: 'member-work-sync:team-a:bob:review-pickup:evt-review-request',
|
messageId: 'member-work-sync:team-a:bob:review-pickup:evt-review-request',
|
||||||
|
|
|
||||||
|
|
@ -368,7 +368,10 @@ function createService(params: {
|
||||||
logPaths: string[];
|
logPaths: string[];
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
|
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
|
||||||
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> };
|
taskChangePresenceRepository?: {
|
||||||
|
upsertEntry: ReturnType<typeof vi.fn>;
|
||||||
|
deleteEntry?: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
teamLogSourceTracker?: {
|
teamLogSourceTracker?: {
|
||||||
ensureTracking: ReturnType<
|
ensureTracking: ReturnType<
|
||||||
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>>
|
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>>
|
||||||
|
|
@ -1098,24 +1101,70 @@ describe('ChangeExtractorService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes needs_attention presence entries for warning-only task diff results', async () => {
|
it('clears cached presence for diagnostic-only multi-scope task diff results', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
await writeTaskFile(tmpDir);
|
await writeTaskFile(tmpDir);
|
||||||
|
|
||||||
const upsertEntry = vi.fn(async () => undefined);
|
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||||
const ensureTracking = vi.fn(async () => ({
|
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
||||||
projectFingerprint: 'project-fingerprint',
|
const ensureTracking = vi.fn(() =>
|
||||||
logSourceGeneration: 'log-generation',
|
Promise.resolve({
|
||||||
}));
|
projectFingerprint: 'project-fingerprint',
|
||||||
|
logSourceGeneration: 'log-generation',
|
||||||
|
})
|
||||||
|
);
|
||||||
const workerClient = {
|
const workerClient = {
|
||||||
isAvailable: vi.fn(() => true),
|
isAvailable: vi.fn(() => true),
|
||||||
computeTaskChanges: vi.fn(async () =>
|
computeTaskChanges: vi.fn(() =>
|
||||||
makeTaskChangeResult(TASK_ID, {
|
Promise.resolve(
|
||||||
content: '',
|
makeTaskChangeResult(TASK_ID, {
|
||||||
confidence: 'fallback',
|
content: '',
|
||||||
warning: 'Ledger skipped attribution because multiple task scopes were active.',
|
confidence: 'fallback',
|
||||||
})
|
warning: 'Ledger skipped attribution because multiple task scopes were active.',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const { service } = createService({
|
||||||
|
logPaths: [],
|
||||||
|
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
||||||
|
teamLogSourceTracker: { ensureTracking },
|
||||||
|
taskChangeWorkerClient: workerClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||||
|
|
||||||
|
expect(result.files).toHaveLength(0);
|
||||||
|
expect(result.warnings).toEqual([
|
||||||
|
'Ledger skipped attribution because multiple task scopes were active.',
|
||||||
|
]);
|
||||||
|
expect(upsertEntry).not.toHaveBeenCalled();
|
||||||
|
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, TASK_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes needs_attention presence entries for unclassified warning-only task diff results', async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||||
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
await writeTaskFile(tmpDir);
|
||||||
|
|
||||||
|
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||||
|
const ensureTracking = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
projectFingerprint: 'project-fingerprint',
|
||||||
|
logSourceGeneration: 'log-generation',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const workerClient = {
|
||||||
|
isAvailable: vi.fn(() => true),
|
||||||
|
computeTaskChanges: vi.fn(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
makeTaskChangeResult(TASK_ID, {
|
||||||
|
content: '',
|
||||||
|
confidence: 'fallback',
|
||||||
|
warning: 'Unexpected ledger warning.',
|
||||||
|
})
|
||||||
|
)
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const { service } = createService({
|
const { service } = createService({
|
||||||
|
|
@ -1128,9 +1177,7 @@ describe('ChangeExtractorService', () => {
|
||||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||||
|
|
||||||
expect(result.files).toHaveLength(0);
|
expect(result.files).toHaveLength(0);
|
||||||
expect(result.warnings).toEqual([
|
expect(result.warnings).toEqual(['Unexpected ledger warning.']);
|
||||||
'Ledger skipped attribution because multiple task scopes were active.',
|
|
||||||
]);
|
|
||||||
expect(upsertEntry).toHaveBeenCalledWith(
|
expect(upsertEntry).toHaveBeenCalledWith(
|
||||||
TEAM_NAME,
|
TEAM_NAME,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import * as fs from 'fs/promises';
|
||||||
|
|
||||||
import { JsonTaskChangeSummaryCacheRepository } from '../../../../src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository';
|
import { JsonTaskChangeSummaryCacheRepository } from '../../../../src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository';
|
||||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
import { resolveTaskChangePresenceFromResult } from '../../../../src/shared/utils/taskChangePresence';
|
||||||
|
|
||||||
import type { PersistedTaskChangeSummaryEntry } from '../../../../src/main/services/team/cache/taskChangeSummaryCacheTypes';
|
import type { PersistedTaskChangeSummaryEntry } from '../../../../src/main/services/team/cache/taskChangeSummaryCacheTypes';
|
||||||
|
|
||||||
|
|
@ -96,6 +97,64 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
|
||||||
).toContain('"teamName": "team-a"');
|
).toContain('"teamName": "team-a"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves review classification metadata when loading cached entries', async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
||||||
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
const repo = new JsonTaskChangeSummaryCacheRepository();
|
||||||
|
|
||||||
|
await repo.save(
|
||||||
|
buildEntry({
|
||||||
|
summary: {
|
||||||
|
...buildEntry().summary,
|
||||||
|
diffStatCompleteness: 'partial',
|
||||||
|
reviewDiagnostics: [
|
||||||
|
{
|
||||||
|
code: 'summary_reconstructed',
|
||||||
|
severity: 'info',
|
||||||
|
reviewBlocking: false,
|
||||||
|
message: 'The change summary was reconstructed from the task-change journal.',
|
||||||
|
source: 'summary',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
provenance: {
|
||||||
|
sourceKind: 'ledger',
|
||||||
|
sourceFingerprint: 'ledger-fingerprint',
|
||||||
|
integrity: 'partial',
|
||||||
|
bundleSchemaVersion: 2,
|
||||||
|
journalStamp: {
|
||||||
|
events: { bytes: 10, mtimeMs: 1000, tailSha256: 'events-tail' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const loaded = await repo.load('team-a', '1');
|
||||||
|
|
||||||
|
expect(loaded?.summary.diffStatCompleteness).toBe('partial');
|
||||||
|
expect(loaded?.summary ? resolveTaskChangePresenceFromResult(loaded.summary) : null).toBe(
|
||||||
|
'needs_attention'
|
||||||
|
);
|
||||||
|
expect(loaded?.summary.reviewDiagnostics).toEqual([
|
||||||
|
{
|
||||||
|
code: 'summary_reconstructed',
|
||||||
|
severity: 'info',
|
||||||
|
reviewBlocking: false,
|
||||||
|
message: 'The change summary was reconstructed from the task-change journal.',
|
||||||
|
source: 'summary',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(loaded?.summary.provenance).toMatchObject({
|
||||||
|
sourceKind: 'ledger',
|
||||||
|
sourceFingerprint: 'ledger-fingerprint',
|
||||||
|
integrity: 'partial',
|
||||||
|
bundleSchemaVersion: 2,
|
||||||
|
journalStamp: {
|
||||||
|
events: { bytes: 10, mtimeMs: 1000, tailSha256: 'events-tail' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('treats expired entries as cache misses', async () => {
|
it('treats expired entries as cache misses', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
@ -110,7 +169,7 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
const repo = new JsonTaskChangeSummaryCacheRepository();
|
const repo = new JsonTaskChangeSummaryCacheRepository();
|
||||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
const filePath = path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json');
|
const filePath = path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json');
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
await fs.writeFile(filePath, '{bad-json', 'utf8');
|
await fs.writeFile(filePath, '{bad-json', 'utf8');
|
||||||
|
|
|
||||||
352
test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts
Normal file
352
test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
getPath: () => os.tmpdir(),
|
||||||
|
getVersion: () => '1.3.0-e2e',
|
||||||
|
isPackaged: false,
|
||||||
|
},
|
||||||
|
BrowserWindow: vi.fn(),
|
||||||
|
dialog: {},
|
||||||
|
ipcMain: { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn() },
|
||||||
|
nativeImage: { createFromPath: vi.fn(() => ({})) },
|
||||||
|
net: {},
|
||||||
|
Notification: vi.fn(),
|
||||||
|
safeStorage: {
|
||||||
|
decryptString: vi.fn(),
|
||||||
|
encryptString: vi.fn(),
|
||||||
|
isEncryptionAvailable: vi.fn(() => false),
|
||||||
|
},
|
||||||
|
shell: { openExternal: vi.fn(), showItemInFolder: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createMemberWorkSyncFeature } from '@features/member-work-sync/main';
|
||||||
|
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||||
|
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||||
|
import { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||||
|
import { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
||||||
|
import { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||||
|
import { TeamTaskWriter } from '@main/services/team/TeamTaskWriter';
|
||||||
|
import {
|
||||||
|
getTasksBasePath,
|
||||||
|
getTeamsBasePath,
|
||||||
|
setClaudeBasePathOverride,
|
||||||
|
} from '@main/utils/pathDecoder';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createOpenCodeLiveHarness,
|
||||||
|
waitForOpenCodeLanesStopped,
|
||||||
|
} from './openCodeLiveTestHarness';
|
||||||
|
|
||||||
|
const liveDescribe =
|
||||||
|
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_REVIEW_PICKUP === '1'
|
||||||
|
? describe
|
||||||
|
: describe.skip;
|
||||||
|
|
||||||
|
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
|
||||||
|
const MODEL = process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle';
|
||||||
|
|
||||||
|
liveDescribe('OpenCode review pickup live e2e', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let tempClaudeRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-review-pickup-e2e-'));
|
||||||
|
tempClaudeRoot = path.join(tempDir, '.claude');
|
||||||
|
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||||
|
setClaudeBasePathOverride(tempClaudeRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') {
|
||||||
|
console.info(`[OpenCodeReviewPickup.live] preserved temp dir: ${tempDir}`);
|
||||||
|
} else {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'delivers review pickup when the current unread review request is still in the foreground inbox',
|
||||||
|
async () => {
|
||||||
|
const harness = await createOpenCodeLiveHarness({
|
||||||
|
tempDir,
|
||||||
|
selectedModel: MODEL,
|
||||||
|
projectPath: PROJECT_PATH,
|
||||||
|
});
|
||||||
|
const feature = createMemberWorkSyncFeature({
|
||||||
|
teamsBasePath: getTeamsBasePath(),
|
||||||
|
configReader: new TeamConfigReader(),
|
||||||
|
taskReader: new TeamTaskReader(),
|
||||||
|
kanbanManager: new TeamKanbanManager(),
|
||||||
|
membersMetaStore: new TeamMembersMetaStore(),
|
||||||
|
isTeamActive: () => true,
|
||||||
|
queueQuietWindowMs: 0,
|
||||||
|
extraBusySignals: [
|
||||||
|
{
|
||||||
|
isBusy: (input) => harness.svc.getOpenCodeMemberDeliveryBusyStatus(input),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reviewPickupDelivery: {
|
||||||
|
canDeliver: (input) =>
|
||||||
|
input.providerId === 'opencode'
|
||||||
|
? { ok: true }
|
||||||
|
: {
|
||||||
|
ok: false,
|
||||||
|
reason: `provider_not_supported:${input.providerId ?? 'unknown'}`,
|
||||||
|
},
|
||||||
|
deliver: async (input) => {
|
||||||
|
const relay = await harness.svc.relayOpenCodeMemberInboxMessages(
|
||||||
|
input.teamName,
|
||||||
|
input.memberName,
|
||||||
|
{
|
||||||
|
onlyMessageId: input.messageId,
|
||||||
|
source: 'member-work-sync-review-pickup',
|
||||||
|
deliveryMetadata: {
|
||||||
|
actionMode: input.payload.actionMode,
|
||||||
|
taskRefs: input.payload.taskRefs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const lastDelivery = relay.lastDelivery;
|
||||||
|
const diagnostics = [
|
||||||
|
...(relay.diagnostics ?? []),
|
||||||
|
...(lastDelivery?.diagnostics ?? []),
|
||||||
|
];
|
||||||
|
if (lastDelivery?.accepted === true && lastDelivery.responsePending === true) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
state: 'prompt_accepted' as const,
|
||||||
|
messageId: input.messageId,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lastDelivery?.delivered && lastDelivery.accepted !== false) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
state: lastDelivery.responsePending
|
||||||
|
? ('prompt_accepted' as const)
|
||||||
|
: ('response_proven' as const),
|
||||||
|
messageId: input.messageId,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason:
|
||||||
|
lastDelivery?.ledgerStatus === 'failed_terminal'
|
||||||
|
? ('terminal_failure' as const)
|
||||||
|
: ('retryable_failure' as const),
|
||||||
|
message: lastDelivery?.reason ?? 'opencode_review_pickup_delivery_not_confirmed',
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamName = `opencode-review-pickup-${Date.now()}`;
|
||||||
|
const memberName = 'bob';
|
||||||
|
const taskId = '7142f765-76e5-4532-8a37-e228b841a6ed';
|
||||||
|
const displayId = '7142f765';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const progressEvents: Array<{ message?: string }> = [];
|
||||||
|
await harness.svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: PROJECT_PATH,
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: MODEL,
|
||||||
|
skipPermissions: true,
|
||||||
|
members: [{ name: memberName, role: 'Reviewer', providerId: 'opencode', model: MODEL }],
|
||||||
|
},
|
||||||
|
(progress) => {
|
||||||
|
progressEvents.push(progress);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
progressEvents.some((progress) =>
|
||||||
|
String(progress.message ?? '').includes('OpenCode team launch is ready')
|
||||||
|
),
|
||||||
|
JSON.stringify(progressEvents, null, 2)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
await new TeamTaskWriter().createTask(teamName, {
|
||||||
|
id: taskId,
|
||||||
|
displayId,
|
||||||
|
subject: 'Live review pickup e2e task',
|
||||||
|
description: 'Verify review-pickup delivery over its own unread review request.',
|
||||||
|
owner: 'alice',
|
||||||
|
createdBy: 'lead',
|
||||||
|
status: 'completed',
|
||||||
|
reviewState: 'review',
|
||||||
|
projectPath: PROJECT_PATH,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||||
|
const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
|
||||||
|
task.historyEvents = [
|
||||||
|
...(Array.isArray(task.historyEvents) ? task.historyEvents : []),
|
||||||
|
{
|
||||||
|
id: 'evt-live-review-request',
|
||||||
|
type: 'review_requested',
|
||||||
|
timestamp: new Date(Date.now() + 1000).toISOString(),
|
||||||
|
from: 'approved',
|
||||||
|
to: 'review',
|
||||||
|
reviewer: memberName,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
task.updatedAt = new Date().toISOString();
|
||||||
|
await fs.writeFile(taskPath, `${JSON.stringify(task, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
await new TeamInboxWriter().sendMessage(teamName, {
|
||||||
|
member: memberName,
|
||||||
|
from: 'team-lead',
|
||||||
|
to: memberName,
|
||||||
|
messageId: 'live-review-request-without-taskrefs',
|
||||||
|
source: 'system_notification',
|
||||||
|
summary: `Review request for #${displayId}`,
|
||||||
|
text: [
|
||||||
|
`**Please review** task #${displayId}`,
|
||||||
|
'',
|
||||||
|
'FIRST call review_start to signal you are beginning the review:',
|
||||||
|
`{ teamName: "${teamName}", taskId: "${taskId}", from: "<your-name>" }`,
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await feature.refreshStatus({ teamName, memberName });
|
||||||
|
expect(status.state).toBe('needs_sync');
|
||||||
|
expect(status.agenda.items[0]).toMatchObject({
|
||||||
|
taskId,
|
||||||
|
kind: 'review',
|
||||||
|
evidence: {
|
||||||
|
reviewObligation: 'review_pickup_required',
|
||||||
|
reviewRequestEventId: 'evt-live-review-request',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskRef = { teamName, taskId, displayId };
|
||||||
|
await expect(
|
||||||
|
harness.svc.getOpenCodeMemberDeliveryBusyStatus({
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
nowIso: new Date().toISOString(),
|
||||||
|
workSyncIntent: 'review_pickup',
|
||||||
|
taskRefs: [taskRef],
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ busy: false });
|
||||||
|
|
||||||
|
const outboxPath = path.join(
|
||||||
|
getTeamsBasePath(),
|
||||||
|
teamName,
|
||||||
|
'members',
|
||||||
|
memberName,
|
||||||
|
'.member-work-sync',
|
||||||
|
'outbox.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
const reconciledBefore = feature.getQueueDiagnostics().reconciled;
|
||||||
|
feature.noteTeamChange({
|
||||||
|
type: 'member-turn-settled',
|
||||||
|
teamName,
|
||||||
|
detail: JSON.stringify({
|
||||||
|
memberName,
|
||||||
|
sourceId: 'review-pickup-live-e2e',
|
||||||
|
provider: 'opencode',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForQueueReconciled(feature, reconciledBefore + 1, 45_000);
|
||||||
|
const reviewItem = await waitForReviewPickupOutboxDelivery(outboxPath, 180_000);
|
||||||
|
|
||||||
|
expect(reviewItem).toMatchObject({
|
||||||
|
status: 'delivered',
|
||||||
|
});
|
||||||
|
expect(reviewItem?.lastError).not.toBe('member_busy:opencode_foreground_inbox_unread');
|
||||||
|
} finally {
|
||||||
|
await feature.dispose().catch(() => undefined);
|
||||||
|
await harness.svc.stopTeam(teamName).catch(() => undefined);
|
||||||
|
await harness.dispose().catch(() => undefined);
|
||||||
|
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
300_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForQueueReconciled(
|
||||||
|
feature: ReturnType<typeof createMemberWorkSyncFeature>,
|
||||||
|
expectedReconciled: number,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let diagnostics = feature.getQueueDiagnostics();
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
diagnostics = feature.getQueueDiagnostics();
|
||||||
|
if (diagnostics.reconciled >= expectedReconciled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (diagnostics.failed > 0 && diagnostics.queued === 0 && diagnostics.running === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for member-work-sync queue reconcile. Diagnostics: ${JSON.stringify(
|
||||||
|
diagnostics,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReviewPickupOutboxDelivery(
|
||||||
|
outboxPath: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<{ status?: string; deliveryState?: string; lastError?: string }> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastOutbox: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const outbox = JSON.parse(await fs.readFile(outboxPath, 'utf8'));
|
||||||
|
lastOutbox = outbox;
|
||||||
|
const reviewItem = Object.values(outbox.items ?? outbox).find(
|
||||||
|
(entry) =>
|
||||||
|
(entry as { payload?: { workSyncIntent?: string } }).payload?.workSyncIntent ===
|
||||||
|
'review_pickup'
|
||||||
|
) as { status?: string; deliveryState?: string; lastError?: string } | undefined;
|
||||||
|
if (reviewItem?.status === 'delivered') {
|
||||||
|
return reviewItem;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
reviewItem?.status === 'failed_terminal' ||
|
||||||
|
reviewItem?.lastError === 'member_busy:opencode_foreground_inbox_unread'
|
||||||
|
) {
|
||||||
|
throw new Error(`Review pickup failed: ${JSON.stringify(reviewItem, null, 2)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for review pickup outbox delivery. Last outbox: ${JSON.stringify(
|
||||||
|
lastOutbox,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
getOpenCodeTeamRuntimeDirectory,
|
getOpenCodeTeamRuntimeDirectory,
|
||||||
inspectOpenCodeRuntimeLaneStorage,
|
inspectOpenCodeRuntimeLaneStorage,
|
||||||
migrateLegacyOpenCodeRuntimeState,
|
migrateLegacyOpenCodeRuntimeState,
|
||||||
|
prepareOpenCodeRuntimeLaneForLaunchGeneration,
|
||||||
readCommittedOpenCodeBootstrapSessionEvidence,
|
readCommittedOpenCodeBootstrapSessionEvidence,
|
||||||
readOpenCodeRuntimeLaneIndex,
|
readOpenCodeRuntimeLaneIndex,
|
||||||
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
||||||
|
|
@ -690,3 +691,330 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('prepareOpenCodeRuntimeLaneForLaunchGeneration', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
const teamName = 'team-launch-generation';
|
||||||
|
const laneId = 'secondary:opencode:bob';
|
||||||
|
const now = new Date('2026-05-09T10:00:00.000Z');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-generation-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function writeSessionStoreForRun(runId: string): Promise<void> {
|
||||||
|
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
|
||||||
|
(candidate) => candidate.schemaName === 'opencode.sessionStore'
|
||||||
|
);
|
||||||
|
if (!descriptor) throw new Error('session descriptor missing');
|
||||||
|
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
|
||||||
|
const runtimeDirectory = path.dirname(manifestPath);
|
||||||
|
await fs.mkdir(runtimeDirectory, { recursive: true });
|
||||||
|
const writer = new RuntimeStoreBatchWriter(
|
||||||
|
runtimeDirectory,
|
||||||
|
createRuntimeStoreManifestStore({
|
||||||
|
filePath: manifestPath,
|
||||||
|
teamName,
|
||||||
|
clock: () => now,
|
||||||
|
}),
|
||||||
|
createRuntimeStoreReceiptStore({
|
||||||
|
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
clock: () => now,
|
||||||
|
batchIdFactory: () => `batch-${runId}`,
|
||||||
|
receiptIdFactory: () => `receipt-${runId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await writer.writeBatch({
|
||||||
|
teamName,
|
||||||
|
runId,
|
||||||
|
capabilitySnapshotId: null,
|
||||||
|
behaviorFingerprint: null,
|
||||||
|
reason: 'launch_checkpoint',
|
||||||
|
writes: [
|
||||||
|
{
|
||||||
|
descriptor,
|
||||||
|
data: {
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: `session-${runId}`,
|
||||||
|
teamName,
|
||||||
|
memberName: 'bob',
|
||||||
|
runId,
|
||||||
|
laneId,
|
||||||
|
providerId: 'opencode',
|
||||||
|
source: 'runtime_bootstrap_checkin',
|
||||||
|
observedAt: now.toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManifest() {
|
||||||
|
return createRuntimeStoreManifestStore({
|
||||||
|
filePath: getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId),
|
||||||
|
teamName,
|
||||||
|
}).read();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a fresh active manifest when the lane has no manifest', async () => {
|
||||||
|
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-new',
|
||||||
|
reason: 'test_launch',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readManifest()).resolves.toMatchObject({
|
||||||
|
activeRunId: 'run-new',
|
||||||
|
highWatermark: 0,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
|
||||||
|
lanes: {
|
||||||
|
[laneId]: {
|
||||||
|
laneId,
|
||||||
|
state: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ reset: false, reason: 'fresh_manifest_created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses a same-generation manifest without clearing runtime evidence', async () => {
|
||||||
|
await writeSessionStoreForRun('run-current');
|
||||||
|
await setOpenCodeRuntimeActiveRunManifest({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-current',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-current',
|
||||||
|
reason: 'test_launch',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readManifest()).resolves.toMatchObject({
|
||||||
|
activeRunId: 'run-current',
|
||||||
|
highWatermark: 1,
|
||||||
|
entries: [expect.objectContaining({ runId: 'run-current' })],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
fs.readFile(
|
||||||
|
getOpenCodeLaneScopedRuntimeFilePath({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
fileName: 'opencode-sessions.json',
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
).resolves.toContain('session-run-current');
|
||||||
|
expect(result).toMatchObject({ reset: false, reason: 'same_generation_reused' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets runtime evidence when activeRunId belongs to an older run', async () => {
|
||||||
|
await writeSessionStoreForRun('run-old');
|
||||||
|
await setOpenCodeRuntimeActiveRunManifest({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-old',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-new',
|
||||||
|
reason: 'test_launch',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readManifest()).resolves.toMatchObject({
|
||||||
|
activeRunId: 'run-new',
|
||||||
|
highWatermark: 0,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
fs.readFile(
|
||||||
|
getOpenCodeLaneScopedRuntimeFilePath({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
fileName: 'opencode-sessions.json',
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(result).toMatchObject({ reset: true, reason: 'active_run_mismatch' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets when manifest entries belong to an older run even if activeRunId was advanced', async () => {
|
||||||
|
await writeSessionStoreForRun('run-old');
|
||||||
|
await setOpenCodeRuntimeActiveRunManifest({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-new',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-new',
|
||||||
|
reason: 'test_launch',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readManifest()).resolves.toMatchObject({
|
||||||
|
activeRunId: 'run-new',
|
||||||
|
highWatermark: 0,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ reset: true, reason: 'stale_manifest_entries' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets entries without a run id because they cannot prove the current generation', async () => {
|
||||||
|
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
|
||||||
|
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
manifestPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
data: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
teamName,
|
||||||
|
activeRunId: 'run-new',
|
||||||
|
activeCapabilitySnapshotId: null,
|
||||||
|
activeBehaviorFingerprint: null,
|
||||||
|
highWatermark: 1,
|
||||||
|
lastCommittedBatchId: null,
|
||||||
|
lastPreparingBatchId: null,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
schemaName: 'opencode.runtimeDiagnostics',
|
||||||
|
schemaVersion: 1,
|
||||||
|
relativePath: 'opencode-diagnostics.json',
|
||||||
|
contentHash: null,
|
||||||
|
fileSize: null,
|
||||||
|
mtimeMs: null,
|
||||||
|
runId: null,
|
||||||
|
capabilitySnapshotId: null,
|
||||||
|
behaviorFingerprint: null,
|
||||||
|
lastWriteReceiptId: null,
|
||||||
|
state: 'healthy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lastRecoveryPlanId: null,
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-new',
|
||||||
|
reason: 'test_launch',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readManifest()).resolves.toMatchObject({
|
||||||
|
activeRunId: 'run-new',
|
||||||
|
highWatermark: 0,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ reset: true, reason: 'stale_manifest_entries' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets unreadable manifests safely', async () => {
|
||||||
|
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
|
||||||
|
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||||
|
await fs.writeFile(manifestPath, '{not-json', 'utf8');
|
||||||
|
|
||||||
|
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-new',
|
||||||
|
reason: 'test_launch',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readManifest()).resolves.toMatchObject({
|
||||||
|
activeRunId: 'run-new',
|
||||||
|
highWatermark: 0,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ reset: true, reason: 'manifest_unreadable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets degraded or stopped lane index state before launch', async () => {
|
||||||
|
await writeSessionStoreForRun('run-current');
|
||||||
|
await setOpenCodeRuntimeActiveRunManifest({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-current',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
state: 'degraded',
|
||||||
|
diagnostics: ['previous launch failed'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||||
|
teamsBasePath: tempDir,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'run-current',
|
||||||
|
reason: 'test_launch',
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readManifest()).resolves.toMatchObject({
|
||||||
|
activeRunId: 'run-current',
|
||||||
|
highWatermark: 0,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
|
||||||
|
lanes: {
|
||||||
|
[laneId]: {
|
||||||
|
laneId,
|
||||||
|
state: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ reset: true, reason: 'lane_index_terminal' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -6638,6 +6638,82 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps OpenCode inbox relay unread and surfaces a clear reason when the model is not vision-capable', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const sendMessageToMember = vi.fn();
|
||||||
|
await configureOpenCodeBobDeliveryService({
|
||||||
|
svc,
|
||||||
|
sendMessageToMember,
|
||||||
|
memberModel: 'openrouter/z-ai/glm-5.1',
|
||||||
|
});
|
||||||
|
await (svc as any).attachmentStore.saveAttachments('team-a', 'msg-unsupported-image-model', [
|
||||||
|
{
|
||||||
|
id: 'att-unsupported-model',
|
||||||
|
filename: 'diagram.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
size: 5,
|
||||||
|
data: 'aW1nMQ==',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
|
||||||
|
await fsPromises.mkdir(inboxDir, { recursive: true });
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
path.join(inboxDir, 'bob.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
from: 'team-lead',
|
||||||
|
to: 'bob',
|
||||||
|
text: 'Review this image.',
|
||||||
|
timestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'msg-unsupported-image-model',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: 'att-unsupported-model',
|
||||||
|
filename: 'diagram.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
size: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const relay = await svc.relayOpenCodeMemberInboxMessages('team-a', 'bob', {
|
||||||
|
onlyMessageId: 'msg-unsupported-image-model',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(relay).toMatchObject({
|
||||||
|
attempted: 1,
|
||||||
|
delivered: 0,
|
||||||
|
failed: 1,
|
||||||
|
relayed: 0,
|
||||||
|
lastDelivery: {
|
||||||
|
delivered: false,
|
||||||
|
reason: 'attachment_model_unsupported',
|
||||||
|
userVisibleImpact: {
|
||||||
|
state: 'error',
|
||||||
|
reasonCode: 'backend_error',
|
||||||
|
message:
|
||||||
|
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(relay.diagnostics?.join('\n')).toContain(
|
||||||
|
'opencode_attachment_delivery_prepare_failed: This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||||
|
);
|
||||||
|
expect(sendMessageToMember).not.toHaveBeenCalled();
|
||||||
|
const rows = JSON.parse(
|
||||||
|
await fsPromises.readFile(path.join(inboxDir, 'bob.json'), 'utf8')
|
||||||
|
) as Array<{ read?: boolean }>;
|
||||||
|
expect(rows[0]?.read).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps OpenCode inbox relay unread when attachment payload data is unavailable', async () => {
|
it('keeps OpenCode inbox relay unread when attachment payload data is unavailable', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn();
|
const sendMessageToMember = vi.fn();
|
||||||
|
|
@ -12525,7 +12601,7 @@ describe('TeamProvisioningService', () => {
|
||||||
providerBackendId: 'codex-native',
|
providerBackendId: 'codex-native',
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
},
|
},
|
||||||
() => {}
|
vi.fn()
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
|
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
|
||||||
|
|
@ -13063,6 +13139,302 @@ describe('TeamProvisioningService', () => {
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resets stale OpenCode lane manifests before launch and retries exact stale watermark once', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'safe-mixed-opencode-stale-manifest-recovery';
|
||||||
|
const laneId = 'secondary:opencode:bob';
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||||
|
await writeCommittedOpenCodeSessionStore({
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'old-opencode-run',
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'old-session-bob',
|
||||||
|
teamName,
|
||||||
|
memberName: 'bob',
|
||||||
|
laneId,
|
||||||
|
runId: 'old-opencode-run',
|
||||||
|
source: 'runtime_bootstrap_checkin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await setOpenCodeRuntimeActiveRunManifest({
|
||||||
|
teamsBasePath: tempTeamsBase,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'old-opencode-run',
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
|
||||||
|
const runId = String(input.runId);
|
||||||
|
const manifest = await createRuntimeStoreManifestStore({
|
||||||
|
filePath: getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId),
|
||||||
|
teamName,
|
||||||
|
}).read();
|
||||||
|
expect(manifest).toMatchObject({
|
||||||
|
activeRunId: runId,
|
||||||
|
highWatermark: 0,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (adapterLaunch.mock.calls.length === 1) {
|
||||||
|
throw new Error('OpenCode bridge failed: Bridge server runtime manifest high watermark is stale');
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeCommittedOpenCodeSessionStore({
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'fresh-session-bob',
|
||||||
|
teamName,
|
||||||
|
memberName: 'bob',
|
||||||
|
laneId,
|
||||||
|
runId,
|
||||||
|
source: 'runtime_bootstrap_checkin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
teamName,
|
||||||
|
launchPhase: 'finished',
|
||||||
|
teamLaunchState: 'clean_success',
|
||||||
|
members: {
|
||||||
|
bob: {
|
||||||
|
memberName: 'bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { svc } = createSafeLaunchService();
|
||||||
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: adapterLaunch,
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { runId } = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
effort: 'medium',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'minimax/m2.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = (svc as any).runs.get(runId);
|
||||||
|
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||||
|
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2), { timeout: 5_000 });
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
async () => {
|
||||||
|
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
expect(publicStatuses.statuses.bob).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps stale OpenCode lane manifest recovery bounded when the bridge stays stale', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'safe-mixed-opencode-stale-manifest-terminal';
|
||||||
|
const laneId = 'secondary:opencode:bob';
|
||||||
|
const staleWatermarkError =
|
||||||
|
'OpenCode bridge failed: Bridge server runtime manifest high watermark is stale';
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||||
|
await writeCommittedOpenCodeSessionStore({
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'old-opencode-run',
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'old-session-bob',
|
||||||
|
teamName,
|
||||||
|
memberName: 'bob',
|
||||||
|
laneId,
|
||||||
|
runId: 'old-opencode-run',
|
||||||
|
source: 'runtime_bootstrap_checkin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await setOpenCodeRuntimeActiveRunManifest({
|
||||||
|
teamsBasePath: tempTeamsBase,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId: 'old-opencode-run',
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapterLaunch = vi.fn(async () => {
|
||||||
|
throw new Error(staleWatermarkError);
|
||||||
|
});
|
||||||
|
const { svc } = createSafeLaunchService();
|
||||||
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: adapterLaunch,
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { runId } = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
effort: 'medium',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'minimax/m2.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = (svc as any).runs.get(runId);
|
||||||
|
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||||
|
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2), { timeout: 5_000 });
|
||||||
|
await vi.waitFor(
|
||||||
|
async () => {
|
||||||
|
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
expect(publicStatuses.statuses.bob).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(publicStatuses.statuses.bob)).toContain(staleWatermarkError);
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry non-stale OpenCode provider launch failures as manifest recovery', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'safe-mixed-opencode-provider-failure-no-stale-retry';
|
||||||
|
const providerError =
|
||||||
|
'OpenCode quota exhausted. This request requires more credits, or fewer max_tokens.';
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||||
|
|
||||||
|
const adapterLaunch = vi.fn(async () => {
|
||||||
|
throw new Error(providerError);
|
||||||
|
});
|
||||||
|
const { svc } = createSafeLaunchService();
|
||||||
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: adapterLaunch,
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { runId } = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
effort: 'medium',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'minimax/m2.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = (svc as any).runs.get(runId);
|
||||||
|
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||||
|
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(1), { timeout: 5_000 });
|
||||||
|
await vi.waitFor(
|
||||||
|
async () => {
|
||||||
|
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
expect(publicStatuses.statuses.bob).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(publicStatuses.statuses.bob)).toContain(providerError);
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
|
|
||||||
it('restores missing OpenCode teammates into config before post-launch registration audit', async () => {
|
it('restores missing OpenCode teammates into config before post-launch registration audit', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'mixed-opencode-post-launch-config';
|
const teamName = 'mixed-opencode-post-launch-config';
|
||||||
|
|
|
||||||
|
|
@ -2131,7 +2131,7 @@ Messages:
|
||||||
`/mock/teams/${teamName}/config.json`,
|
`/mock/teams/${teamName}/config.json`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
name: teamName,
|
name: teamName,
|
||||||
projectPath: '/tmp/my-team',
|
projectPath: '/mock/my-team',
|
||||||
members: [
|
members: [
|
||||||
{ name: 'team-lead', agentType: 'team-lead' },
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||||
|
|
@ -3127,6 +3127,86 @@ Messages:
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat the current unread OpenCode review request as busy for review-pickup checks', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
const laneId = 'secondary:opencode:jack';
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
hoisted.files.set(
|
||||||
|
`${teamsBasePath}/${teamName}/config.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
name: teamName,
|
||||||
|
projectPath: '/tmp/my-team',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
hoisted.files.set(
|
||||||
|
`${teamsBasePath}/${teamName}/inboxes/jack.json`,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
from: 'team-lead',
|
||||||
|
to: 'jack',
|
||||||
|
text: '**Please review** task #task1234\n\nFIRST call review_start.',
|
||||||
|
timestamp: '2026-02-23T17:31:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'review-request-1',
|
||||||
|
source: 'system_notification',
|
||||||
|
summary: 'Review request for #task1234',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
canonicalMemberName: 'jack',
|
||||||
|
laneId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockReturnValue(
|
||||||
|
Promise.resolve({
|
||||||
|
version: 1,
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
lanes: {
|
||||||
|
[laneId]: {
|
||||||
|
laneId,
|
||||||
|
state: 'active',
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||||
|
getActiveForMember: vi.fn(() => Promise.resolve(null)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||||
|
teamName,
|
||||||
|
memberName: 'jack',
|
||||||
|
nowIso: '2026-02-23T17:31:10.000Z',
|
||||||
|
workSyncIntent: 'review_pickup',
|
||||||
|
taskRefs: [{ teamName, taskId: 'task-1234', displayId: 'task1234' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(busy).toEqual({ busy: false });
|
||||||
|
|
||||||
|
const mismatchedTaskBusy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||||
|
teamName,
|
||||||
|
memberName: 'jack',
|
||||||
|
nowIso: '2026-02-23T17:31:10.000Z',
|
||||||
|
workSyncIntent: 'review_pickup',
|
||||||
|
taskRefs: [{ teamName, taskId: 'other-task', displayId: 'other' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mismatchedTaskBusy).toMatchObject({
|
||||||
|
busy: true,
|
||||||
|
reason: 'opencode_foreground_inbox_unread',
|
||||||
|
activeMessageId: 'review-request-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not treat unread OpenCode work-sync nudges as foreground busy blockers', async () => {
|
it('does not treat unread OpenCode work-sync nudges as foreground busy blockers', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,10 @@ async function writeTaskFile(baseDir: string, taskId: string, projectPath: strin
|
||||||
|
|
||||||
function createLedgerBackedChangeExtractorService(params: {
|
function createLedgerBackedChangeExtractorService(params: {
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> };
|
taskChangePresenceRepository?: {
|
||||||
|
upsertEntry: ReturnType<typeof vi.fn>;
|
||||||
|
deleteEntry?: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
teamLogSourceTracker?: {
|
teamLogSourceTracker?: {
|
||||||
ensureTracking: ReturnType<
|
ensureTracking: ReturnType<
|
||||||
typeof vi.fn<
|
typeof vi.fn<
|
||||||
|
|
@ -752,7 +755,7 @@ describe('task change ledger golden fixtures', () => {
|
||||||
expect(computeTaskChanges).not.toHaveBeenCalled();
|
expect(computeTaskChanges).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records needs_attention presence from warning-only ledger fixtures', async () => {
|
it('clears cached presence from diagnostic-only warning ledger fixtures', async () => {
|
||||||
const fixture = await materializeTaskChangeLedgerFixture('notices-only');
|
const fixture = await materializeTaskChangeLedgerFixture('notices-only');
|
||||||
cleanups.push(fixture.cleanup);
|
cleanups.push(fixture.cleanup);
|
||||||
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-'));
|
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-'));
|
||||||
|
|
@ -762,14 +765,17 @@ describe('task change ledger golden fixtures', () => {
|
||||||
setClaudeBasePathOverride(claudeBaseDir);
|
setClaudeBasePathOverride(claudeBaseDir);
|
||||||
await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir);
|
await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir);
|
||||||
|
|
||||||
const upsertEntry = vi.fn(async () => undefined);
|
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||||
const ensureTracking = vi.fn(async () => ({
|
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
||||||
projectFingerprint: 'fixture-project-fingerprint',
|
const ensureTracking = vi.fn(() =>
|
||||||
logSourceGeneration: 'fixture-log-generation',
|
Promise.resolve({
|
||||||
}));
|
projectFingerprint: 'fixture-project-fingerprint',
|
||||||
|
logSourceGeneration: 'fixture-log-generation',
|
||||||
|
})
|
||||||
|
);
|
||||||
const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({
|
const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({
|
||||||
projectDir: fixture.projectDir,
|
projectDir: fixture.projectDir,
|
||||||
taskChangePresenceRepository: { upsertEntry },
|
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
||||||
teamLogSourceTracker: { ensureTracking },
|
teamLogSourceTracker: { ensureTracking },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -784,16 +790,7 @@ describe('task change ledger golden fixtures', () => {
|
||||||
'Task change ledger skipped attribution because multiple task scopes were active.'
|
'Task change ledger skipped attribution because multiple task scopes were active.'
|
||||||
);
|
);
|
||||||
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
|
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
|
||||||
expect(upsertEntry).toHaveBeenCalledWith(
|
expect(upsertEntry).not.toHaveBeenCalled();
|
||||||
TEAM_NAME,
|
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, fixture.manifest.taskId);
|
||||||
expect.objectContaining({
|
|
||||||
projectFingerprint: 'fixture-project-fingerprint',
|
|
||||||
logSourceGeneration: 'fixture-log-generation',
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
taskId: fixture.manifest.taskId,
|
|
||||||
presence: 'needs_attention',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ describe('changeReviewSlice task changes', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats warning-only summaries as needs_attention and rechecks after invalidation', async () => {
|
it('treats diagnostic-only multi-scope summaries as unknown and rechecks after invalidation', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
const teamName = 'team-a';
|
const teamName = 'team-a';
|
||||||
const taskId = 'presence-warning';
|
const taskId = 'presence-warning';
|
||||||
|
|
@ -328,17 +328,115 @@ describe('changeReviewSlice task changes', () => {
|
||||||
|
|
||||||
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
||||||
|
|
||||||
|
expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith(
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
'needs_attention'
|
||||||
|
);
|
||||||
|
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
|
||||||
|
|
||||||
|
store.getState().invalidateTaskChangePresence([cacheKey]);
|
||||||
|
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
||||||
|
|
||||||
|
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats unclassified warning-only summaries as needs_attention', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const teamName = 'team-a';
|
||||||
|
const taskId = 'presence-unclassified-warning';
|
||||||
|
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
|
||||||
|
hoisted.getTaskChanges.mockResolvedValue({
|
||||||
|
files: [],
|
||||||
|
totalFiles: 0,
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
confidence: 'fallback',
|
||||||
|
computedAt: '2026-03-01T12:00:00.000Z',
|
||||||
|
scope: {
|
||||||
|
taskId,
|
||||||
|
memberName: '',
|
||||||
|
startLine: 0,
|
||||||
|
endLine: 0,
|
||||||
|
startTimestamp: '',
|
||||||
|
endTimestamp: '',
|
||||||
|
toolUseIds: [],
|
||||||
|
filePaths: [],
|
||||||
|
confidence: { tier: 4, label: 'fallback', reason: 'Unknown warning' },
|
||||||
|
},
|
||||||
|
warnings: ['Unexpected ledger warning.'],
|
||||||
|
provenance: {
|
||||||
|
sourceKind: 'ledger',
|
||||||
|
sourceFingerprint: 'ledger-warning-only',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
||||||
|
|
||||||
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
'needs_attention'
|
'needs_attention'
|
||||||
);
|
);
|
||||||
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('needs_attention');
|
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('needs_attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('background revalidates cached needs_attention presence', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const teamName = 'team-a';
|
||||||
|
const taskId = 'cached-needs-attention';
|
||||||
|
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
|
||||||
|
store.setState({
|
||||||
|
selectedTeamName: teamName,
|
||||||
|
selectedTeamData: {
|
||||||
|
tasks: [{ id: taskId, changePresence: 'needs_attention' }],
|
||||||
|
},
|
||||||
|
taskChangePresenceByKey: { [cacheKey]: 'needs_attention' },
|
||||||
|
});
|
||||||
|
hoisted.getTaskChanges.mockResolvedValue({
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
files: [],
|
||||||
|
totalFiles: 0,
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
confidence: 'fallback',
|
||||||
|
computedAt: '2026-03-01T12:00:00.000Z',
|
||||||
|
scope: {
|
||||||
|
taskId,
|
||||||
|
memberName: '',
|
||||||
|
startLine: 0,
|
||||||
|
endLine: 0,
|
||||||
|
startTimestamp: '',
|
||||||
|
endTimestamp: '',
|
||||||
|
toolUseIds: [],
|
||||||
|
filePaths: [],
|
||||||
|
confidence: { tier: 4, label: 'fallback', reason: 'Multi-scope notice only' },
|
||||||
|
},
|
||||||
|
warnings: ['Task change ledger skipped attribution because multiple task scopes were active.'],
|
||||||
|
});
|
||||||
|
|
||||||
store.getState().invalidateTaskChangePresence([cacheKey]);
|
|
||||||
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
|
expect(hoisted.getTaskChanges).toHaveBeenCalledWith(teamName, taskId, {
|
||||||
|
...OPTIONS_A,
|
||||||
|
summaryOnly: true,
|
||||||
|
forceFresh: true,
|
||||||
|
});
|
||||||
|
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
|
||||||
|
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
'needs_attention'
|
||||||
|
);
|
||||||
|
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
'unknown'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not raise needs_attention for active interval summaries with no observed file edits yet', async () => {
|
it('does not raise needs_attention for active interval summaries with no observed file edits yet', async () => {
|
||||||
|
|
@ -737,6 +835,40 @@ describe('changeReviewSlice task changes', () => {
|
||||||
await warmPromise;
|
await warmPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears stale no_changes warm cache entries for diagnostic-only summaries', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const teamName = 'team-a';
|
||||||
|
const taskId = 'warm-diagnostic-only';
|
||||||
|
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
|
||||||
|
store.setState({ taskChangePresenceByKey: { [cacheKey]: 'no_changes' } });
|
||||||
|
hoisted.getTaskChanges.mockResolvedValue({
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
files: [],
|
||||||
|
totalFiles: 0,
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
confidence: 'fallback',
|
||||||
|
computedAt: '2026-03-01T12:00:00.000Z',
|
||||||
|
scope: {
|
||||||
|
taskId,
|
||||||
|
memberName: '',
|
||||||
|
startLine: 0,
|
||||||
|
endLine: 0,
|
||||||
|
startTimestamp: '',
|
||||||
|
endTimestamp: '',
|
||||||
|
toolUseIds: [],
|
||||||
|
filePaths: [],
|
||||||
|
confidence: { tier: 4, label: 'fallback', reason: 'Multi-scope notice only' },
|
||||||
|
},
|
||||||
|
warnings: ['Task change ledger skipped attribution because multiple task scopes were active.'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().warmTaskChangeSummaries([{ teamName, taskId, options: OPTIONS_A }]);
|
||||||
|
|
||||||
|
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('clears optimistic terminal presence after background forceFresh revalidation', async () => {
|
it('clears optimistic terminal presence after background forceFresh revalidation', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
const teamName = 'team-revalidate';
|
const teamName = 'team-revalidate';
|
||||||
|
|
|
||||||
106
test/renderer/utils/attachmentRecipientCapabilities.test.ts
Normal file
106
test/renderer/utils/attachmentRecipientCapabilities.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAttachmentInputAcceptForMember,
|
||||||
|
getMemberAttachmentUnavailableReason,
|
||||||
|
validateAttachmentFilesForMember,
|
||||||
|
validateAttachmentPayloadsForMember,
|
||||||
|
} from '../../../src/renderer/utils/attachmentRecipientCapabilities';
|
||||||
|
|
||||||
|
import type { AttachmentPayload, ResolvedTeamMember } from '../../../src/shared/types';
|
||||||
|
|
||||||
|
function member(overrides: Partial<ResolvedTeamMember>): ResolvedTeamMember {
|
||||||
|
return {
|
||||||
|
name: 'bob',
|
||||||
|
status: 'idle',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function file(name: string, type: string, bytes = 12): File {
|
||||||
|
return new File([new Uint8Array(bytes)], name, { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
function payload(overrides: Partial<AttachmentPayload>): AttachmentPayload {
|
||||||
|
return {
|
||||||
|
id: 'att-1',
|
||||||
|
filename: 'diagram.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
size: 12,
|
||||||
|
data: 'aW1n',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('attachmentRecipientCapabilities', () => {
|
||||||
|
it('blocks OpenCode non-vision models before file selection or send', () => {
|
||||||
|
const bob = member({
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/z-ai/glm-5.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getMemberAttachmentUnavailableReason(bob)).toBe(
|
||||||
|
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||||
|
);
|
||||||
|
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBe(
|
||||||
|
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||||
|
);
|
||||||
|
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBe(
|
||||||
|
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows image picker input for verified OpenCode vision models', () => {
|
||||||
|
const bob = member({
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getMemberAttachmentUnavailableReason(bob)).toBeNull();
|
||||||
|
expect(getAttachmentInputAcceptForMember(bob)).toBe('image/png,image/jpeg,image/webp');
|
||||||
|
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBeNull();
|
||||||
|
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks non-image files for image-only providers', () => {
|
||||||
|
const codexLead = member({
|
||||||
|
name: 'lead',
|
||||||
|
agentType: 'team-lead',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateAttachmentFilesForMember({ member: codexLead, files: [file('notes.md', 'text/markdown')] })).toBe(
|
||||||
|
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
validateAttachmentPayloadsForMember({
|
||||||
|
member: codexLead,
|
||||||
|
attachments: [payload({ filename: 'notes.md', mimeType: 'text/plain' })],
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows text/PDF files for Anthropic lead recipients', () => {
|
||||||
|
const anthropicLead = member({
|
||||||
|
name: 'lead',
|
||||||
|
agentType: 'team-lead',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateAttachmentFilesForMember({ member: anthropicLead, files: [file('brief.pdf', 'application/pdf')] })).toBeNull();
|
||||||
|
expect(
|
||||||
|
validateAttachmentPayloadsForMember({
|
||||||
|
member: anthropicLead,
|
||||||
|
attachments: [payload({ filename: 'brief.pdf', mimeType: 'application/pdf' })],
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -193,4 +193,55 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
|
||||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode created a reply without the required taskRefs metadata.'
|
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode created a reply without the required taskRefs metadata.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces unsupported OpenCode attachment models as an actionable failure', () => {
|
||||||
|
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
|
||||||
|
deliveredToInbox: true,
|
||||||
|
messageId: 'msg-unsupported-attachment-model',
|
||||||
|
runtimeDelivery: {
|
||||||
|
providerId: 'opencode',
|
||||||
|
attempted: true,
|
||||||
|
delivered: false,
|
||||||
|
responsePending: false,
|
||||||
|
reason: 'attachment_model_unsupported',
|
||||||
|
diagnostics: [
|
||||||
|
'opencode_attachment_delivery_prepare_failed: This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.',
|
||||||
|
],
|
||||||
|
userVisibleImpact: {
|
||||||
|
state: 'error',
|
||||||
|
reasonCode: 'backend_error',
|
||||||
|
message:
|
||||||
|
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diagnostics.warning).toBe(
|
||||||
|
'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment. Reason: This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||||
|
);
|
||||||
|
expect(diagnostics.debugDetails).toMatchObject({
|
||||||
|
reason: 'attachment_model_unsupported',
|
||||||
|
userVisibleMessage:
|
||||||
|
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps legacy unsupported attachment model codes to an actionable failure', () => {
|
||||||
|
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
|
||||||
|
deliveredToInbox: true,
|
||||||
|
messageId: 'msg-legacy-unsupported-attachment-model',
|
||||||
|
runtimeDelivery: {
|
||||||
|
providerId: 'opencode',
|
||||||
|
attempted: true,
|
||||||
|
delivered: false,
|
||||||
|
responsePending: false,
|
||||||
|
reason: 'attachment_model_unsupported',
|
||||||
|
diagnostics: ['attachment_model_unsupported'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diagnostics.warning).toBe(
|
||||||
|
'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment. Reason: This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue