Files
kandev/docs/plans/opencode-actionable-error-links/plan.md
T

235 lines
12 KiB
Markdown

---
spec: docs/specs/agent-stall-recovery/spec.md
decision: docs/decisions/2026-08-07-allowlisted-provider-action-links.md
created: 2026-08-07
status: implemented
---
# Implementation Plan: OpenCode Actionable Error Links
## Outcome
Carry an optional, validated OpenCode remediation URL from an ACP/managed
diagnostic into Kandev's existing recoverable-error lifecycle, then render it
as a localized external link in every recovery surface. The short error remains
the primary copy; raw stderr and raw ACP error payloads remain excluded.
This plan is an incremental repair to the completed OpenCode terminal-error
surfacing work. It does not change prompt correlation, retry behavior, session
states, or recovery actions.
## Mobile design contract
The recovery link is an inline addition to the existing recovery cards and
notices; there is no new navigation or surface.
- Entry point: same chat/office surface on every viewport.
- Nearest shipped exemplar: the provider-quota recovery card
(`mobile-provider-quota-recovery.spec.ts`); the link reuses its inline
composition and 44px phone touch sizing (`min-h-11` below `sm`, `min-h-8`
above).
- Presentation: inline text link next to the existing recovery actions —
no drawer, dialog, or new route (content depth does not justify one).
- Scroll owner: unchanged chat panel; the link is `break-words`-safe and adds
no horizontal scroll.
- Touch/focus: minimum 44px phone target, native anchor keyboard reachability,
`target="_blank"` + `rel="noopener noreferrer"`.
- Mobile Playwright: `mobile-provider-remediation-link.spec.ts` proves the
link, focus, 44px target, URL-free details, and zero document overflow.
## Root-cause constraint
The reported OpenCode 1.18.5 ACP failure contains only the short message in
Kandev's ACP error, logs, and managed stderr. OpenCode's TUI has additional
provider-side context, including the workspace URL, but that context is not
transported by the ACP service-failure response. No Kandev-only parser can
display bytes that never crossed the managed process boundary.
The Kandev half therefore supports the optional field now and keeps a truthful
fallback. A separate upstream OpenCode change is required for the exact
1.18.5-style failure to produce a link: OpenCode must emit an optional
`action_url` in its ACP service-failure data or in its already-supported
structured error-only stderr record. The plan must not add a private-log tail or
raw-error side channel to work around that dependency.
## Contract and security rules
- Add optional `remediation_url` to the normalized safe provider diagnostic and
persist the same field in `LastAgentError` and recovery metadata.
- Accept only `https://opencode.ai/workspace/<safe-workspace-id>/go` with an
exact host, HTTPS scheme, no userinfo/query/fragment, bounded identifier, and
bounded total length.
- Extract the URL before sanitizing the provider message; the message must
remain URL-free and identifier-redacted.
- For a future ACP `RequestError`, read only the structured `action_url` and
safe message fields; never derive a browser link by copying `err.Error()`.
- Keep the URL out of generic logs, recent-stderr projections, raw ACP payloads,
and technical text. Render it as a separate link with `noopener noreferrer`.
- If validation or transport fails, preserve the current generic recovery card
and current short message.
## Backend work
### 1. Normalize transport-provided action URLs
Extend the existing OpenCode stderr parser and ACP prompt-error projection with
one shared allowlist validator. Structured stderr extracts a valid URL from the
provider error before sanitization; future ACP service-failure data accepts only
the explicit structured field. The provider diagnostic remains bounded and
optional, and all existing session/generation correlation rules stay intact.
### 2. Persist and publish the safe field
Extend `LastAgentError` with the validated URL and add the URL to generic
recovery-message metadata independently of quota classification. Reuse the
existing session metadata and `session.state_changed` snapshot so reloads,
Kanban banners, and Office failed-session rows see the same value. Do not change
the session's plain `error_message` contract.
### 3. Preserve existing classification behavior
Keep quota classification and `error_output` URL-free. A provider diagnostic may
carry a remediation URL even when it is not quota-classified, so the generic
recoverable card can still show the link. Tests must prove that URL-bearing
metadata never appears for invalid or untrusted inputs.
## Frontend work
### 4. Render one shared, mobile-safe link affordance
Add defense-in-depth URL validation at the browser boundary and a small shared
localized link component. Use it from:
- Kanban `ActionMessageDetails` and the persistent last-agent-error notice;
- Office `RunErrorEntry`, including the live `session.state_changed` metadata
projection.
The link is separate from the collapsed technical text, opens in a new tab with
`rel="noopener noreferrer"`, and has a minimum 44px touch target on phones.
The existing recovery buttons, error copy, and chat scroll ownership remain
unchanged. New labels go through the existing `task`/`chat` translation files
and their pseudo-locale counterparts.
## Tests and verification
### Backend focused tests
- Parser accepts the observed OpenCode workspace route, stores it only in
`remediation_url`, and keeps the URL/workspace ID out of `message` and
sanitized stderr.
- Parser rejects wrong hosts, schemes, paths, query/fragment components,
malformed IDs, overlong values, and unrelated URLs.
- ACP structured error data with `action_url` follows the same allowlist; absent
or malformed data keeps the current generic error path.
- Lifecycle and orchestrator tests prove the field survives only through the
winning error event, is persisted in `LastAgentError`/recovery metadata, and
is absent for invalid input.
### Frontend unit tests
- `ActionMessage` renders a validated link for generic and quota recovery while
preserving collapsed technical details and existing actions.
- `readLastAgentError` and the persistent notice retain/render the safe field.
- Office session mapping and `buildRunErrorsFromSessions` preserve the field;
`RunErrorEntry` renders the same link and remains safe for invalid metadata.
- English and pseudo-locale checks pass; no hardcoded user-facing copy is added.
### E2E tests
Add seeded desktop and `mobile-chrome` scenarios that inject only the safe
`remediation_url` metadata. Verify the link destination, no URL in the
sanitized message, keyboard/touch reachability, 44px controls, and no
horizontal overflow. Add a no-link case for the current short ACP failure.
These tests must not invoke a real provider, exhaust quota, or read an
OpenCode log.
Targeted commands:
```text
cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api ./internal/agent/runtime/lifecycle ./internal/agent/runtime/routingerr ./internal/orchestrator -run 'Test(OpenCode|ProviderError|RecoverableFailure|RecoveryStatus)' -count=1
cd apps && pnpm --filter @kandev/web test -- --run components/task/chat/messages/action-message.test.tsx lib/session-last-agent-error.test.ts components/task/simple/chat-entries.test.ts lib/remediation-url.test.ts
cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet
cd apps/web && pnpm e2e:run --no-build tests/session/provider-remediation-link.spec.ts
cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/session/mobile-provider-remediation-link.spec.ts
cd apps/web && pnpm e2e:run --no-build tests/office/provider-remediation-link.spec.ts
```
## Likely files
Backend:
- `apps/backend/internal/agentctl/types/streams/provider_error.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go`
- `apps/backend/internal/agentctl/server/api/agent.go`
- `apps/backend/internal/agentctl/server/api/agent_test.go`
- `apps/backend/internal/task/models/models.go`
- `apps/backend/internal/orchestrator/event_handlers_agent.go`
- `apps/backend/internal/orchestrator/event_handlers_test.go`
- `apps/backend/internal/orchestrator/recovery_actions_test.go`
Frontend:
- `apps/web/lib/session-last-agent-error.ts`
- `apps/web/components/task/chat/messages/action-message.tsx`
- `apps/web/components/task/chat/messages/action-message.test.tsx`
- `apps/web/components/task/chat/message-list-shared.tsx`
- `apps/web/app/office/tasks/[id]/page.tsx`
- `apps/web/app/office/tasks/[id]/types.ts`
- `apps/web/components/task/simple/chat-entries.ts`
- `apps/web/components/task/simple/components/run-error-entry.tsx`
- `apps/web/src/locales/en/task.json`
- `apps/web/src/locales/en/chat.json`
- matching pseudo-locale files and focused tests
## Sequencing and handoff
The work is sequential because the frontend consumes the persisted metadata
contract. There is no safe parallel implementation wave:
1. Normalize and test the transport contract.
2. Persist/publish it through the existing lifecycle.
3. Render both recovery surfaces and live Office updates.
4. Run focused unit, backend, i18n, desktop, and mobile verification.
The external OpenCode emission change is a prerequisite for live URL coverage
of the reported 1.18.5 error, but not for Kandev's parser, persistence, or
seeded UI tests. If upstream does not emit the field, Kandev must retain the
short-error fallback and report that limitation rather than exposing private
logs.
## Verification Results
Implemented 2026-08-07.
- Backend allowlist validator `NormalizeOpenCodeActionURL` in
`apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_remediation.go`,
shared by the stderr parser (inline URL or `action_url` field) and the
structured ACP `RequestError` projection. `ProviderError.RemediationURL`,
`LastAgentError.RemediationURL`, recovery-message `remediation_url`, and the
`task_session.error_changed` event all carry the field; `session.state_changed`
carries it via the persisted `session_metadata`.
- Frontend: `lib/remediation-url.ts` browser-edge validation, shared
`components/task/remediation-link.tsx` (`target="_blank"`, `rel="noopener noreferrer"`,
min-h-11 touch target), wired into `ActionMessageDetails` (generic + quota
cards), the persistent `LastAgentErrorNotice`, and Office `RunErrorEntry`
(initial API data + live `session.state_changed` metadata merge).
- Generic logs: the `session.state_changed` metadata debug echo redacts
`remediation_url` (`redactRemediationURL` in `task_notifications.go`).
- Verification runs (all green):
- `cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api ./internal/agent/runtime/lifecycle ./internal/agent/runtime/routingerr ./internal/orchestrator -run 'Test(OpenCode|ProviderError|RecoverableFailure|RecoveryStatus)' -count=1` — passed.
- Full touched-package backend suites (acp, streams, task/models, orchestrator,
lifecycle, agentctl api/process, gateway/websocket) — passed.
- `golangci-lint run ./... --new-from-rev=<base> --timeout=5m` — no issues.
- `pnpm --filter @kandev/web test -- --run lib/remediation-url.test.ts lib/session-last-agent-error.test.ts components/task/chat/messages/action-message.test.tsx components/task/chat/message-list-shared.test.tsx components/task/simple/chat-entries.test.ts` — 106 passed.
- Full web unit suite — 1236 files / 9669 tests passed.
- `pnpm run typecheck`, `pnpm run i18n:check`, `pnpm run i18n:ratchet` — all clean (pseudo locale regenerated).
- Desktop E2E `tests/session/provider-remediation-link.spec.ts` — 3 passed (link render + keyboard focus, short-error fallback, last-agent-error notice).
- Mobile E2E `tests/session/mobile-provider-remediation-link.spec.ts` (mobile-chrome) — 1 passed (44px target, focus, overflow).
- Office E2E `tests/office/provider-remediation-link.spec.ts` — 2 passed (link on RunErrorEntry, no-link for invalid/absent metadata).
External dependency stands: OpenCode must emit `action_url` (ACP structured
error data or structured stderr) for the live 1.18.5-style failure to show the
TUI workspace URL; Kandev keeps the short-error fallback until then.