feat: surface allowlisted OpenCode remediation links in recovery surfaces (#2396)

This commit is contained in:
Carlos Florêncio
2026-08-07 21:27:43 +01:00
committed by GitHub
parent 9e6c2098b1
commit 9e503c5e6b
41 changed files with 2159 additions and 44 deletions
@@ -0,0 +1,93 @@
package acp
import (
"net/url"
"strings"
)
const (
// maxOpenCodeActionURLBytes bounds the complete validated remediation URL.
// The allowlisted route plus a bounded workspace identifier leaves ample
// headroom while keeping the field a bounded diagnostic.
maxOpenCodeActionURLBytes = 256
openCodeActionURLPrefix = "/workspace/"
openCodeActionURLSuffix = "/go"
openCodeActionURLHost = "opencode.ai"
)
// NormalizeOpenCodeActionURL is the single allowlist validator shared by the
// OpenCode stderr parser and the structured ACP error projection. It accepts
// only `https://opencode.ai/workspace/<safe-workspace-id>/go` with an exact
// host, HTTPS scheme, no userinfo, query, fragment (including empty
// delimiters), percent-encoding, or port, a bounded workspace identifier, and
// a bounded complete URL. Any other input — including arbitrary provider
// URLs — returns "".
func NormalizeOpenCodeActionURL(raw string) string {
if raw == "" || len(raw) > maxOpenCodeActionURLBytes {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
if !hasAllowlistedOpenCodeAuthority(raw, u) || !hasAllowlistedOpenCodePath(u) {
return ""
}
workspaceID := strings.TrimSuffix(strings.TrimPrefix(u.Path, openCodeActionURLPrefix), openCodeActionURLSuffix)
if !safeOpenCodeWorkspaceID(workspaceID) {
return ""
}
return "https://" + openCodeActionURLHost + openCodeActionURLPrefix + workspaceID + openCodeActionURLSuffix
}
// hasAllowlistedOpenCodeAuthority accepts only the exact HTTPS route with no
// userinfo, opaque data, query (including a trailing "?" via ForceQuery),
// fragment, percent-encoding, or port. A trailing "#" is not distinguishable
// in the parsed struct, so the raw input is checked too.
func hasAllowlistedOpenCodeAuthority(raw string, u *url.URL) bool {
if u.Scheme != "https" || u.Host != openCodeActionURLHost {
return false
}
return u.User == nil && u.Opaque == "" && !u.ForceQuery &&
u.RawQuery == "" && u.Fragment == "" && u.RawPath == "" &&
!strings.ContainsRune(raw, '#')
}
func hasAllowlistedOpenCodePath(u *url.URL) bool {
return strings.HasPrefix(u.Path, openCodeActionURLPrefix) &&
strings.HasSuffix(u.Path, openCodeActionURLSuffix)
}
// safeOpenCodeWorkspaceID bounds the workspace identifier to the same
// alphanumeric/underscore/hyphen charset OpenCode uses for its identifiers.
// The dot-colon-slash tolerance of safeOpenCodeField is intentionally not
// reused here so a `..`-style segment cannot pass as a workspace ID.
func safeOpenCodeWorkspaceID(id string) bool {
if id == "" || len(id) > 128 {
return false
}
for _, r := range id {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
// extractOpenCodeActionURL finds the first allowlisted OpenCode action URL in
// the raw provider message, before sanitization strips URLs and identifiers.
// Strictness is intentional: a URL followed by punctuation or prose does not
// validate, and the sanitizer still removes the token from the message.
func extractOpenCodeActionURL(raw string) string {
for _, token := range strings.Fields(raw) {
if !strings.HasPrefix(token, "https://") {
continue
}
if normalized := NormalizeOpenCodeActionURL(token); normalized != "" {
return normalized
}
}
return ""
}
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"go.uber.org/zap"
)
@@ -40,13 +41,52 @@ func (e *providerPromptError) Error() string {
// ProviderErrorFromError extracts the safe provider diagnostic from a prompt
// error without exposing the provider-specific wrapper to lifecycle callers.
// It first unwraps the correlated stderr diagnostic; for a structured ACP
// service-failure it reads only the explicit `action_url` field and the safe
// message — never the raw error string.
func ProviderErrorFromError(err error) *streams.ProviderError {
var providerErr *providerPromptError
if !errors.As(err, &providerErr) || providerErr == nil || !providerErr.ProviderError.Valid() {
if errors.As(err, &providerErr) && providerErr != nil && providerErr.ProviderError.Valid() {
copy := providerErr.ProviderError
return &copy
}
return providerErrorFromACPActionURL(err)
}
// providerErrorFromACPActionURL projects a future ACP service-failure
// response that carries a structured `action_url` into a provider diagnostic.
// The URL must pass the shared allowlist; the message runs through the same
// sanitizer as stderr so it stays URL-free and identifier-redacted.
//
// The contract is intentionally URL-gated: a message-only ACP error (no
// valid `action_url`) falls through to the generic error path rather than
// surfacing an unsanitized ACP message, matching the stderr diagnostic's
// bounded-message rule. OpenCode's current ACP failures therefore keep the
// existing short-error fallback.
func providerErrorFromACPActionURL(err error) *streams.ProviderError {
var reqErr *acp.RequestError
if !errors.As(err, &reqErr) || reqErr == nil {
return nil
}
copy := providerErr.ProviderError
return &copy
data, ok := reqErr.Data.(map[string]any)
if !ok {
return nil
}
rawURL, _ := data["action_url"].(string)
remediationURL := NormalizeOpenCodeActionURL(rawURL)
if remediationURL == "" {
return nil
}
message := sanitizeOpenCodeMessage(reqErr.Message)
if message == "" {
return nil
}
return &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeACP,
Message: message,
RemediationURL: remediationURL,
OccurredAt: time.Now(),
}
}
// ConsumeStderrLine implements adapter.StderrLineConsumer. OpenCode's
@@ -98,6 +138,14 @@ func parseOpenCodeStderrLine(line string) (openCodeStderrDiagnostic, bool) {
if !safeOpenCodeIdentifier(sessionID, "ses_") {
return openCodeStderrDiagnostic{}, false
}
// Capture the allowlisted remediation URL before sanitization. OpenCode may
// carry it in a dedicated `action_url` field or inline in the provider
// error text; either way only the shared validator accepts it, and the
// sanitized message never contains the URL or workspace identifier.
remediationURL := NormalizeOpenCodeActionURL(fields["action_url"])
if remediationURL == "" {
remediationURL = extractOpenCodeActionURL(fields["error.error"])
}
message := sanitizeOpenCodeMessage(fields["error.error"])
if message == "" {
return openCodeStderrDiagnostic{}, false
@@ -108,11 +156,12 @@ func parseOpenCodeStderrLine(line string) (openCodeStderrDiagnostic, bool) {
}
providerError := streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: safeOpenCodeField(fields["providerID"]),
ModelID: safeOpenCodeField(fields["modelID"]),
Message: message,
OccurredAt: occurredAt,
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: safeOpenCodeField(fields["providerID"]),
ModelID: safeOpenCodeField(fields["modelID"]),
Message: message,
RemediationURL: remediationURL,
OccurredAt: occurredAt,
}
if resetAt := openCodeResetAt(message, occurredAt); resetAt != nil {
providerError.ResetAt = resetAt
@@ -13,6 +13,132 @@ import (
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
func TestNormalizeOpenCodeActionURLAcceptsOnlyAllowlistedRoute(t *testing.T) {
const want = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
accepted := []string{
want,
"https://opencode.ai/workspace/a/go",
"https://opencode.ai/workspace/WRK-build_run-42/go",
"https://opencode.ai/workspace/" + strings.Repeat("x", 128) + "/go",
}
for _, raw := range accepted {
if got := NormalizeOpenCodeActionURL(raw); got != raw {
t.Errorf("NormalizeOpenCodeActionURL(%q) = %q, want the input itself", raw, got)
}
}
rejected := []string{
"",
"http://opencode.ai/workspace/wrk_123/go",
"https://opencode.example.com/workspace/wrk_123/go",
"https://opencode.ai/workspace/wrk_123",
"https://opencode.ai/workspace/wrk_123/go/",
"https://opencode.ai/workspace/wrk_123/go/extra",
"https://opencode.ai/workspace/wrk_123/other",
"https://opencode.ai/workspace/wrk_123/go?source=email",
"https://opencode.ai/workspace/wrk_123/go?",
"https://opencode.ai/workspace/wrk_123/go#fragment",
"https://opencode.ai/workspace/wrk_123/go#",
"https://opencode.ai/workspace/wrk_123/go?#",
"https://user:pass@opencode.ai/workspace/wrk_123/go",
"https://opencode.ai:443/workspace/wrk_123/go",
"https://opencode.ai/workspace/wrk_123%2F..%2Fgo",
"https://opencode.ai/workspace/%77rk_123/go",
"https://opencode.ai/workspace/../go",
"https://opencode.ai/workspace/../workspace/wrk_123/go",
"https://opencode.ai/workspace/wrk_123/go extra",
"https://opencode.ai/workspace/wrk_123/go.",
"https://opencode.ai/workspace/" + strings.Repeat("w", 200) + "/go",
"https://example.test/workspace/wrk_123/go",
"not a url",
}
for _, raw := range rejected {
if got := NormalizeOpenCodeActionURL(raw); got != "" {
t.Errorf("NormalizeOpenCodeActionURL(%q) = %q, want empty", raw, got)
}
}
}
func TestExtractOpenCodeActionURLFindsValidTokenInMessage(t *testing.T) {
const want = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
line := "AI_APICallError: 5-hour usage limit reached. To continue using this model now, enable usage from your available balance: " + want
if got := extractOpenCodeActionURL(line); got != want {
t.Fatalf("extractOpenCodeActionURL() = %q, want %q", got, want)
}
for _, raw := range []string{
"provider failed",
"visit https://example.test/workspace/wrk_123/go instead",
"balance: https://opencode.ai/workspace/wrk_123/go.",
"https://opencode.ai/workspace/wrk_123",
} {
if got := extractOpenCodeActionURL(raw); got != "" {
t.Errorf("extractOpenCodeActionURL(%q) = %q, want empty", raw, got)
}
}
}
func TestProviderErrorFromACPRequestErrorReadsOnlyStructuredActionURL(t *testing.T) {
const wantURL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
t.Run("structured action_url projects a safe provider error", func(t *testing.T) {
err := &sdk.RequestError{
Code: -32603,
Message: "AI_APICallError: 5-hour usage limit reached: " + wantURL,
Data: map[string]any{"action_url": wantURL},
}
got := ProviderErrorFromError(err)
if got == nil {
t.Fatal("ProviderErrorFromError() = nil, want provider error")
}
if got.Source != streams.ProviderErrorSourceOpenCodeACP {
t.Fatalf("source = %q", got.Source)
}
if got.RemediationURL != wantURL {
t.Fatalf("remediation URL = %q, want %q", got.RemediationURL, wantURL)
}
if strings.Contains(got.Message, "https://") ||
strings.Contains(got.Message, "wrk_01KQM7K5CYT715264YKKFB17ZY") {
t.Fatalf("message contains private URL or identifier: %q", got.Message)
}
if got.Message == "" {
t.Fatal("message is empty")
}
})
t.Run("missing or malformed action_url keeps the generic error path", func(t *testing.T) {
for _, tt := range []struct {
name string
err error
}{
{name: "no data", err: &sdk.RequestError{Code: -32603, Message: "provider failed"}},
{name: "wrong shape", err: &sdk.RequestError{Code: -32603, Message: "provider failed", Data: "action_url"}},
{name: "wrong host", err: &sdk.RequestError{
Code: -32603, Message: "provider failed",
Data: map[string]any{"action_url": "https://example.test/workspace/wrk_123/go"},
}},
{name: "query", err: &sdk.RequestError{
Code: -32603, Message: "provider failed",
Data: map[string]any{"action_url": wantURL + "?source=email"},
}},
{name: "malformed id", err: &sdk.RequestError{
Code: -32603, Message: "provider failed",
Data: map[string]any{"action_url": "https://opencode.ai/workspace/../go"},
}},
{name: "oversized", err: &sdk.RequestError{
Code: -32603, Message: "provider failed",
Data: map[string]any{"action_url": "https://opencode.ai/workspace/" + strings.Repeat("w", 300) + "/go"},
}},
{name: "wrapped generic error", err: fmt.Errorf("provider failed")},
} {
t.Run(tt.name, func(t *testing.T) {
if got := ProviderErrorFromError(tt.err); got != nil {
t.Fatalf("ProviderErrorFromError() = %+v, want nil", got)
}
})
}
})
}
func TestParseOpenCodeStderrLineAcceptsForegroundStreamError(t *testing.T) {
line := `timestamp=2026-08-02T15:15:44.504Z level=ERROR run=a93db686 message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_03d1ac324ffeA2eAGeBfuq80dq small=false agent=build mode=primary error.error="AI_APICallError: 5-hour usage limit reached. Resets in 4hr 19min. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"`
@@ -47,6 +173,58 @@ func TestParseOpenCodeStderrLineAcceptsForegroundStreamError(t *testing.T) {
if diagnostic.ProviderError.Message == "" {
t.Fatal("message is empty")
}
wantURL := "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
if diagnostic.ProviderError.RemediationURL != wantURL {
t.Fatalf("remediation URL = %q, want %q", diagnostic.ProviderError.RemediationURL, wantURL)
}
}
func TestParseOpenCodeStderrLineCapturesExplicitActionURLField(t *testing.T) {
line := `timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build action_url="https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go" error.error="AI_APICallError: usage limit reached"`
diagnostic, ok := parseOpenCodeStderrLine(line)
if !ok {
t.Fatal("parseOpenCodeStderrLine() rejected an action_url record")
}
wantURL := "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
if diagnostic.ProviderError.RemediationURL != wantURL {
t.Fatalf("remediation URL = %q, want %q", diagnostic.ProviderError.RemediationURL, wantURL)
}
if strings.Contains(diagnostic.ProviderError.Message, "https://") ||
strings.Contains(diagnostic.ProviderError.Message, "wrk_") {
t.Fatalf("message contains private URL or identifier: %q", diagnostic.ProviderError.Message)
}
}
func TestParseOpenCodeStderrLineRejectsUntrustedActionURLs(t *testing.T) {
base := `timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="usage limit reached"`
for _, tt := range []struct {
name string
line string
}{
{
name: "wrong host",
line: strings.Replace(base, `error.error=`, `action_url="https://example.test/workspace/wrk_123/go" error.error=`, 1),
},
{
name: "query",
line: strings.Replace(base, `error.error=`, `action_url="https://opencode.ai/workspace/wrk_123/go?x=1" error.error=`, 1),
},
{
name: "malformed id",
line: strings.Replace(base, `error.error=`, `action_url="https://opencode.ai/workspace/../go" error.error=`, 1),
},
} {
t.Run(tt.name, func(t *testing.T) {
diagnostic, ok := parseOpenCodeStderrLine(tt.line)
if !ok {
t.Fatal("parseOpenCodeStderrLine() rejected the record")
}
if diagnostic.ProviderError.RemediationURL != "" {
t.Fatalf("remediation URL = %q, want empty", diagnostic.ProviderError.RemediationURL)
}
})
}
}
func TestPromptDiagnosticSettlesOnlyMatchingSession(t *testing.T) {
@@ -562,11 +562,22 @@ func (s *Server) handleWSPrompt(ctx context.Context, msg *ws.Message) *ws.Messag
zap.Error(err))
return
}
s.logger.Error("async prompt failed", zap.Error(err))
providerError := acptransport.ProviderErrorFromError(err)
// The raw error string is only safe for the correlated stderr
// diagnostic (its Error() is the sanitized message). A structured
// ACP RequestError serializes its Data — including action_url and
// any other raw provider fields — into Error(), so when a safe
// provider diagnostic exists it must win for both the event and
// the log; never let the raw error cross the boundary.
message := err.Error()
if providerError != nil {
message = providerError.Message
}
s.logger.Error("async prompt failed", zap.String("error_message", message))
s.procMgr.SendErrorEventWithProviderError(
err.Error(),
message,
req.PromptGeneration,
acptransport.ProviderErrorFromError(err),
providerError,
)
}
}()
@@ -13,6 +13,7 @@ import (
"testing"
"time"
"github.com/coder/acp-go-sdk"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/server/adapter"
"github.com/kandev/kandev/internal/agentctl/server/config"
@@ -419,6 +420,62 @@ func TestHandleWSPrompt_NoAdapter(t *testing.T) {
}
}
// TestHandleWSPrompt_SanitizesACPActionURLError is a regression test for the
// structured ACP service-failure path: RequestError.Error() serializes Data
// (action_url and any other raw provider fields), so the sanitized provider
// message must be the only text that reaches the error event and downstream
// persisted/UI error surfaces.
func TestHandleWSPrompt_SanitizesACPActionURLError(t *testing.T) {
s := newTestServer(t)
prompted := make(chan uint64, 1)
const wantURL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
s.procMgr.SetAdapterForTest(&promptErrorAdapter{
sessionID: "session-123",
err: &acp.RequestError{
Code: -32603,
Message: "AI_APICallError: 5-hour usage limit reached: " + wantURL,
Data: map[string]any{
"action_url": wantURL,
"raw_secret": "provider-internal-value",
},
},
prompted: prompted,
})
msg, _ := ws.NewRequest("req-1", "agent.prompt", PromptRequest{
Text: "hello",
PromptGeneration: 42,
})
resp := s.handleWSPrompt(context.Background(), msg)
if resp.Type != ws.MessageTypeResponse {
t.Fatalf("expected response type, got %q", resp.Type)
}
select {
case event := <-s.procMgr.GetUpdates():
if event.Type != adapter.EventTypeError {
t.Fatalf("event type = %q, want error", event.Type)
}
if strings.Contains(event.Error, "https://") ||
strings.Contains(event.Error, "wrk_") ||
strings.Contains(event.Error, "raw_secret") ||
strings.Contains(event.Error, "provider-internal-value") {
t.Fatalf("error message leaks raw ACP data: %q", event.Error)
}
if !strings.Contains(event.Error, "usage limit reached") {
t.Fatalf("error message lost the sanitized copy: %q", event.Error)
}
if event.ProviderError == nil {
t.Fatal("expected a provider error diagnostic")
}
if event.ProviderError.RemediationURL != wantURL {
t.Fatalf("remediation URL = %q, want %q", event.ProviderError.RemediationURL, wantURL)
}
case <-time.After(2 * time.Second):
t.Fatal("expected an error event")
}
}
func TestHandleWSPrompt_SuppressesPromptAbandonedAfterCancel(t *testing.T) {
s := newTestServer(t)
prompted := make(chan uint64, 1)
@@ -2,18 +2,25 @@ package streams
import "time"
const ProviderErrorSourceOpenCodeStderr = "opencode_stderr"
const (
ProviderErrorSourceOpenCodeStderr = "opencode_stderr"
// ProviderErrorSourceOpenCodeACP marks a provider diagnostic projected
// from a structured ACP service-failure response.
ProviderErrorSourceOpenCodeACP = "opencode_acp"
)
// ProviderError is the bounded, sanitized provider diagnostic that may cross
// the agentctl boundary. It intentionally contains no raw stderr or provider
// account/workspace identifiers.
// account/workspace identifiers. RemediationURL is only ever populated by the
// adapter-specific allowlist validator; it is never reconstructed from prose.
type ProviderError struct {
Source string `json:"source,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
ModelID string `json:"model_id,omitempty"`
Message string `json:"message,omitempty"`
OccurredAt time.Time `json:"occurred_at,omitempty"`
ResetAt *time.Time `json:"reset_at,omitempty"`
Source string `json:"source,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
ModelID string `json:"model_id,omitempty"`
Message string `json:"message,omitempty"`
RemediationURL string `json:"remediation_url,omitempty"`
OccurredAt time.Time `json:"occurred_at,omitempty"`
ResetAt *time.Time `json:"reset_at,omitempty"`
}
func (e *ProviderError) Valid() bool {
@@ -196,7 +196,35 @@ func (b *TaskEventBroadcaster) logSessionStateMetadata(action, sessionID string,
b.logger.Debug("received session.state_changed with metadata",
zap.String("action", action),
zap.String("session_id", sessionID),
zap.Any("metadata", metadata))
// RemediationURL is deliberately excluded from the wholesale metadata
// echo (ADR-2026-08-07-allowlisted-provider-action-links): the link is
// a user-visible destination, not a log field.
zap.Any("metadata", redactRemediationURL(metadata)))
}
// redactRemediationURL returns a copy of the metadata with the
// last_agent_error.remediation_url key removed, so the debug echo never
// mutates the event payload that is broadcast to clients.
func redactRemediationURL(metadata interface{}) interface{} {
m, ok := metadata.(map[string]interface{})
if !ok {
return metadata
}
lastErr, ok := m["last_agent_error"].(map[string]interface{})
if !ok {
return metadata
}
copyLastErr := make(map[string]interface{}, len(lastErr))
for key, value := range lastErr {
copyLastErr[key] = value
}
delete(copyLastErr, "remediation_url")
copyMetadata := make(map[string]interface{}, len(m))
for key, value := range m {
copyMetadata[key] = value
}
copyMetadata["last_agent_error"] = copyLastErr
return copyMetadata
}
func (b *TaskEventBroadcaster) routeBroadcast(
@@ -0,0 +1,45 @@
package websocket
import (
"reflect"
"testing"
)
func TestRedactRemediationURLRemovesOnlyTheLinkKey(t *testing.T) {
metadata := map[string]interface{}{
"last_agent_error": map[string]interface{}{
"message": "5-hour usage limit reached",
"remediation_url": "https://opencode.ai/workspace/wrk_123/go",
},
"other_key": "kept",
}
redacted := redactRemediationURL(metadata).(map[string]interface{})
lastErr := redacted["last_agent_error"].(map[string]interface{})
if _, ok := lastErr["remediation_url"]; ok {
t.Fatal("remediation_url still present in the redacted echo")
}
if lastErr["message"] != "5-hour usage limit reached" {
t.Fatalf("message was dropped: %#v", lastErr)
}
if redacted["other_key"] != "kept" {
t.Fatalf("unrelated metadata was dropped: %#v", redacted)
}
// The original event payload must be untouched.
original := metadata["last_agent_error"].(map[string]interface{})
if _, ok := original["remediation_url"]; !ok {
t.Fatal("redaction mutated the source payload")
}
}
func TestRedactRemediationURLPassesThroughUnexpectedShapes(t *testing.T) {
for _, metadata := range []interface{}{
"not a map",
nil,
map[string]interface{}{"last_agent_error": "not a map"},
map[string]interface{}{},
} {
if got := redactRemediationURL(metadata); !reflect.DeepEqual(got, metadata) {
t.Fatalf("redactRemediationURL(%#v) changed the value: %#v", metadata, got)
}
}
}
@@ -1476,6 +1476,7 @@ func (s *Service) persistLastAgentError(ctx context.Context, data watcher.AgentE
Message: errMsg,
OccurredAt: time.Now().UTC(),
AgentExecutionID: data.AgentExecutionID,
RemediationURL: providerRemediationURL(data),
}
if err := s.repo.SetSessionMetadataKey(ctx, data.SessionID, models.SessionMetaKeyLastAgentError, lastErr); err != nil {
s.logger.Warn("failed to persist last agent error",
@@ -1494,6 +1495,9 @@ func (s *Service) persistLastAgentError(ctx context.Context, data watcher.AgentE
"stamp": lastErr.Stamp(),
"agent_execution_id": lastErr.AgentExecutionID,
}
if lastErr.RemediationURL != "" {
eventData["remediation_url"] = lastErr.RemediationURL
}
if err := s.eventBus.Publish(ctx, events.TaskSessionErrorChanged, bus.NewEvent(
events.TaskSessionErrorChanged,
"orchestrator",
@@ -1559,6 +1563,17 @@ func (s *Service) clearRecoveredAgentError(ctx context.Context, taskID string, s
}
}
// providerRemediationURL returns the adapter-validated remediation URL from the
// normalized provider diagnostic, or "" when the failure carried none. The URL
// is only ever set by the adapter's allowlist validator; the orchestrator does
// not validate or reconstruct URLs from prose.
func providerRemediationURL(data watcher.AgentEventData) string {
if data.ProviderError == nil || !data.ProviderError.Valid() {
return ""
}
return data.ProviderError.RemediationURL
}
// createRecoveryStatusMessage builds and persists the ActionMessage shown
// in the kanban chat surface after a recoverable agent failure. Must only
// be called for non-office sessions (office sessions render their own error UI).
@@ -1593,6 +1608,11 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
"is_auth_error": authErr,
"resume_corrupted": resumeCorrupted,
}
// The validated remediation URL is carried independently of quota
// classification so the generic recoverable card can still show the link.
if remediationURL := providerRemediationURL(data); remediationURL != "" {
meta["remediation_url"] = remediationURL
}
applyProviderQuotaMetadata(meta, data)
// Include cached auth methods so the frontend can show login options.
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -2387,6 +2388,74 @@ func TestClearResumeToken(t *testing.T) {
func TestHandleRecoverableFailure(t *testing.T) {
ctx := context.Background()
t.Run("persists validated remediation URL in last agent error metadata", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
const wantURL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
svc.handleRecoverableFailure(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
AgentExecutionID: "exec-1",
ErrorMessage: "usage limit reached",
ProviderError: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
Message: "usage limit reached",
RemediationURL: wantURL,
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
},
})
session, err := repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
lastErr, ok := models.LoadLastAgentError(session.Metadata)
if !ok {
t.Fatalf("expected last agent error metadata, got %#v", session.Metadata)
}
if lastErr.RemediationURL != wantURL {
t.Fatalf("remediation URL = %q, want %q", lastErr.RemediationURL, wantURL)
}
// The plain error_message contract stays URL-free.
if strings.Contains(session.ErrorMessage, "https://") ||
strings.Contains(session.ErrorMessage, "wrk_") {
t.Fatalf("error_message contains private URL or identifier: %q", session.ErrorMessage)
}
})
t.Run("omits remediation URL when the diagnostic carries none", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.handleRecoverableFailure(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
AgentExecutionID: "exec-1",
ErrorMessage: "provider failed",
})
session, err := repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
lastErr, ok := models.LoadLastAgentError(session.Metadata)
if !ok {
t.Fatalf("expected last agent error metadata, got %#v", session.Metadata)
}
if lastErr.RemediationURL != "" {
t.Fatalf("remediation URL = %q, want empty", lastErr.RemediationURL)
}
})
t.Run("sets session to WAITING_FOR_INPUT with error message", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
@@ -105,6 +105,7 @@ func TestCreateRecoveryStatusMessage_OpenCodeQuotaCarriesSafeMetadata(t *testing
mc := &mockMessageCreator{}
svc.messageCreator = mc
resetAt := time.Date(2026, 8, 2, 19, 34, 44, 0, time.UTC)
const wantURL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-quota",
@@ -112,12 +113,13 @@ func TestCreateRecoveryStatusMessage_OpenCodeQuotaCarriesSafeMetadata(t *testing
AgentID: "opencode-acp",
ErrorMessage: "5-hour usage limit reached",
ProviderError: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: "opencode-go",
ModelID: "kimi-k3",
Message: "5-hour usage limit reached",
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
ResetAt: &resetAt,
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: "opencode-go",
ModelID: "kimi-k3",
Message: "5-hour usage limit reached",
RemediationURL: wantURL,
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
ResetAt: &resetAt,
},
})
@@ -137,4 +139,89 @@ func TestCreateRecoveryStatusMessage_OpenCodeQuotaCarriesSafeMetadata(t *testing
if meta["error_output"] != "5-hour usage limit reached" {
t.Fatalf("error_output = %#v", meta["error_output"])
}
if meta["remediation_url"] != wantURL {
t.Fatalf("remediation_url = %#v, want %q", meta["remediation_url"], wantURL)
}
}
func TestCreateRecoveryStatusMessage_GenericFailureCarriesRemediationURL(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t-generic", "s-generic", "step1")
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
mc := &mockMessageCreator{}
svc.messageCreator = mc
const wantURL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"
// A structured ACP-sourced diagnostic is not quota-classified, but the
// validated link still reaches the generic recoverable card.
svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-generic",
SessionID: "s-generic",
AgentID: "opencode-acp",
ErrorMessage: "AI_APICallError: usage limit reached",
ProviderError: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeACP,
Message: "usage limit reached",
RemediationURL: wantURL,
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
},
})
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 session message, got %d", len(mc.sessionMessages))
}
meta := mc.sessionMessages[0].metadata
if meta["failure_kind"] != nil {
t.Fatalf("failure_kind = %#v, want unset for generic failure", meta["failure_kind"])
}
if meta["remediation_url"] != wantURL {
t.Fatalf("remediation_url = %#v, want %q", meta["remediation_url"], wantURL)
}
if _, ok := meta["error_output"]; ok {
t.Fatalf("error_output unexpectedly set for non-quota diagnostic: %#v", meta["error_output"])
}
}
func TestProviderRemediationURLRejectsInvalidDiagnostics(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t-none", "s-none", "step1")
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
mc := &mockMessageCreator{}
svc.messageCreator = mc
for _, tt := range []struct {
name string
providerErr *streams.ProviderError
}{
{name: "no provider error"},
{name: "no remediation url", providerErr: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
Message: "usage limit reached",
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
}},
{name: "invalid provider error", providerErr: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
RemediationURL: "https://opencode.ai/workspace/wrk_123/go",
}},
} {
t.Run(tt.name, func(t *testing.T) {
svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-none",
SessionID: "s-none",
ErrorMessage: "provider failed",
ProviderError: tt.providerErr,
})
if len(mc.sessionMessages) == 0 {
t.Fatal("expected a session message")
}
if url, ok := mc.sessionMessages[0].metadata["remediation_url"]; ok && url != "" {
t.Fatalf("remediation_url = %#v, want absent", url)
}
mc.sessionMessages = nil
})
}
}
@@ -315,10 +315,13 @@ const SessionMetaKeyPendingStepCompletion = "pending_step_completion_signal"
const SessionMetaKeyLastAgentError = "last_agent_error"
// LastAgentError is persisted under TaskSession.Metadata[SessionMetaKeyLastAgentError].
// RemediationURL is only ever set from an adapter-validated provider
// diagnostic; it is never reconstructed from the error message.
type LastAgentError struct {
Message string `json:"message"`
OccurredAt time.Time `json:"occurred_at"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
RemediationURL string `json:"remediation_url,omitempty"`
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
}
+44 -3
View File
@@ -13,6 +13,10 @@ import {
type TaskDecisionDTO,
} from "@/lib/api/domains/office-api";
import { listTaskSessions } from "@/lib/api/domains/session-api";
import {
liveSessionMetadataFromStore,
mergeLiveSessionMetadata,
} from "@/components/task/simple/chat-entries";
import { OfficeSimplePane } from "@/components/task/simple/OfficeSimplePane";
import { TaskAdvancedMode } from "./task-advanced-mode";
import { IssueDetailSkeleton } from "./task-detail-skeleton";
@@ -35,6 +39,11 @@ type IssueDetailPageProps = {
params: Promise<{ id: string }>;
};
// Sentinel for the live-session metadata key: distinguishes "no store entry"
// (keep the initial fetch) from an explicit server-side null (metadata was
// cleared and must not be resurrected).
const SESSION_METADATA_ABSENT = "\u0000absent\u0000";
function mapDecisionDTO(d: TaskDecisionDTO): TaskDecision {
return {
id: d.id,
@@ -166,6 +175,7 @@ function mapTaskSession(session: ApiTaskSession): TaskSession {
completedAt: session.completed_at ?? undefined,
updatedAt: session.updated_at,
errorMessage: session.error_message ?? undefined,
metadata: session.metadata ?? undefined,
commandCount: session.command_count,
};
}
@@ -236,6 +246,34 @@ function useSessionLiveSync({
[sessionStatesKey],
);
// Same stable-key trick for the live metadata (last_agent_error etc.)
// carried by session.state_changed, so the office chat can render the
// remediation link without a refetch. Tri-state per session: the sentinel
// marks "no metadata update" (no store row, or a partial row without a
// metadata field), explicit null means the server cleared metadata, and an
// object is the live metadata.
const sessionMetadataKey = useAppStore((s) => {
const items = s.taskSessions?.items ?? {};
return baseSessions
.map((sess) =>
JSON.stringify(liveSessionMetadataFromStore(items, sess.id) ?? SESSION_METADATA_ABSENT),
)
.join("\u0001");
});
const sessionStoreMetadata = useMemo(
() =>
sessionMetadataKey.split("\u0001").map((chunk) => {
try {
const parsed = JSON.parse(chunk);
if (parsed === SESSION_METADATA_ABSENT) return undefined;
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}),
[sessionMetadataKey],
);
const connectionStatus = useAppStore((s) => s.connection.status);
useSessionLiveSyncSubscriptions({
connectionStatus,
@@ -261,7 +299,7 @@ function useSessionLiveSync({
void onCommentsRefetch();
}, [sessionStatesKey, taskId, onTaskRefetch, onCommentsRefetch]);
return sessionStoreStates;
return { sessionStoreStates, sessionStoreMetadata };
}
// ---------------------------------------------------------------------------
@@ -391,7 +429,7 @@ function useIssueData(id: string) {
setTimeline(updatedTimeline);
}, []);
const sessionStoreStates = useSessionLiveSync({
const { sessionStoreStates, sessionStoreMetadata } = useSessionLiveSync({
task,
baseSessions,
onTaskRefetch,
@@ -402,8 +440,11 @@ function useIssueData(id: string) {
baseSessions.map((s, i) => ({
...s,
state: (sessionStoreStates[i] ?? s.state) as TaskSession["state"],
// Live session.state_changed metadata wins over the initial fetch;
// explicit null (server cleared metadata) is preserved.
metadata: mergeLiveSessionMetadata(s.metadata, sessionStoreMetadata[i]),
})),
[baseSessions, sessionStoreStates],
[baseSessions, sessionStoreStates, sessionStoreMetadata],
);
// Refetch comments when a new comment is created via office WS event
+4
View File
@@ -83,6 +83,8 @@ export type TaskSession = {
updatedAt?: string;
/** Verbatim error payload populated when state === "FAILED". */
errorMessage?: string;
/** Server metadata (incl. last_agent_error); refreshed by session.state_changed. */
metadata?: Record<string, unknown> | null;
/** Server-resolved tool_call count; powers the "ran N commands" segment
* of the timeline entry header without a per-session message fetch. */
commandCount?: number;
@@ -99,6 +101,8 @@ export type RunError = {
agentProfileId?: string;
rawPayload: string;
failedAt: string;
/** Adapter-validated provider remediation URL from last_agent_error metadata. */
remediationUrl?: string;
};
export type TaskLabelLocal = {
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildGroupedRenderItems, type RenderItem } from "@/hooks/use-processed-messages";
import type { Message } from "@/lib/types/http";
@@ -13,6 +13,10 @@ function elementWithRect(top: number, bottom: number): HTMLElement {
el.getBoundingClientRect = () => ({ top, bottom }) as DOMRect;
return el;
}
const AGENT_ERROR_MESSAGE = "agent process exited";
// NOTE: mockStoreState is vi.hoisted, so it cannot reference the const above —
// the factory runs before module initialization.
const mockStoreState = vi.hoisted(() => ({
taskSessions: {
items: {
@@ -233,17 +237,20 @@ describe("MessageItem memo boundary", () => {
});
describe("MessageItem agent error notice", () => {
it("shows retained agent errors even when there are no messages", () => {
afterEach(() => {
cleanup();
});
const REMEDIATION_URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
function renderNotice(error: { message: string; occurredAt?: string; remediationUrl?: string }) {
render(
<MessageItem
item={{
type: "agent_error_notice",
id: "last-agent-error-s1-2026-06-14T12:00:00Z",
sessionId: "s1",
error: {
message: "agent process exited",
occurredAt: "2026-06-14T12:00:00Z",
},
error,
}}
sessionId="s1"
permissionsByToolCallId={perm}
@@ -254,9 +261,33 @@ describe("MessageItem agent error notice", () => {
onScrollToMessage={noop}
/>,
);
}
it("shows retained agent errors even when there are no messages", () => {
renderNotice({ message: AGENT_ERROR_MESSAGE });
expect(screen.getByTestId("last-agent-error-notice").getAttribute("role")).toBe("alert");
expect(screen.queryByText("agent process exited")).not.toBeNull();
expect(screen.getByTestId("last-agent-error-notice").textContent).toContain(
AGENT_ERROR_MESSAGE,
);
});
it("renders a validated remediation link, and nothing for an invalid URL", () => {
renderNotice({
message: "usage limit reached",
remediationUrl: REMEDIATION_URL,
});
const link = screen.getByTestId("remediation-link") as HTMLAnchorElement;
expect(link.href).toBe(REMEDIATION_URL);
expect(link.rel).toBe("noopener noreferrer");
expect(screen.getByTestId("last-agent-error-notice").textContent).toContain(
"usage limit reached",
);
cleanup();
renderNotice({ message: "usage limit reached", remediationUrl: "https://evil.example.com/x" });
expect(screen.queryByTestId("remediation-link")).toBeNull();
});
});
@@ -11,6 +11,7 @@ import { TurnGroupMessage } from "@/components/task/chat/messages/turn-group-mes
import { PrepareProgress } from "@/components/session/prepare-progress";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { dismissLastAgentError } from "@/lib/api/domains/session-api";
import { RemediationLink } from "@/components/task/remediation-link";
import {
type LastAgentError,
lastAgentErrorStamp,
@@ -329,6 +330,11 @@ export function LastAgentErrorNotice({
<pre className="mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap break-words text-[11px] leading-relaxed text-destructive/85">
{error.message}
</pre>
{error.remediationUrl && (
<div className="mt-1">
<RemediationLink url={error.remediationUrl} className="text-destructive/85" />
</div>
)}
</div>
<button
type="button"
@@ -29,6 +29,7 @@ afterEach(() => {
const CANCEL_TEST_ID = "recovery-cancel-retry-button";
const TECHNICAL_DETAILS = "Technical details";
const RECOVERY_MESSAGE = "Agent encountered an error";
const RESUME_LABEL = "Resume session";
const RESUME_TEST_ID = "recovery-resume-button";
const STALL_CANCEL_TEST_ID = "stall-cancel-turn-button";
@@ -75,7 +76,7 @@ function recoveryMessage(withParams = false): Message {
actions: [
{
type: "ws_request",
label: "Resume session",
label: RESUME_LABEL,
test_id: RESUME_TEST_ID,
...(withParams
? {
@@ -334,7 +335,7 @@ describe("ActionMessage — provider quota recovery", () => {
actions: [
{
type: "ws_request",
label: "Resume session",
label: RESUME_LABEL,
test_id: RESUME_TEST_ID,
},
],
@@ -376,3 +377,66 @@ describe("ActionMessage — provider quota recovery", () => {
expect(screen.getByText(/when the provider makes capacity available/i)).toBeTruthy();
});
});
describe("ActionMessage — remediation link", () => {
const REMEDIATION_URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
const QUOTA_OUTPUT = "5-hour usage limit reached";
function recoveryMeta(remediationUrl?: string): Message {
return retryMessage({
content: RECOVERY_MESSAGE,
metadata: {
variant: "error",
recovery_actions: true,
remediation_url: remediationUrl,
error_output: "usage limit reached",
actions: [{ type: "ws_request", label: RESUME_LABEL, test_id: RESUME_TEST_ID }],
},
} as Partial<Message>);
}
it("renders a validated remediation link for quota recovery", () => {
renderAction(
retryMessage({
content: "provider quota reached",
metadata: {
variant: "error",
recovery_actions: true,
failure_kind: "provider_quota_limited",
provider_name: "OpenCode",
error_output: QUOTA_OUTPUT,
remediation_url: REMEDIATION_URL,
actions: [{ type: "ws_request", label: RESUME_LABEL, test_id: RESUME_TEST_ID }],
},
} as Partial<Message>),
"WAITING_FOR_INPUT",
);
const link = screen.getByTestId("remediation-link") as HTMLAnchorElement;
expect(link.href).toBe(REMEDIATION_URL);
expect(link.target).toBe("_blank");
expect(link.rel).toBe("noopener noreferrer");
expect(link.className).toContain("min-h-11");
// The sanitized message and collapsed details stay URL-free.
expect(screen.queryByText(/opencode\.ai\/workspace/i)).toBeNull();
expect(screen.getByTestId("provider-quota-recovery").textContent).toContain(QUOTA_OUTPUT);
});
it("renders a remediation link for a generic recovery card too", () => {
renderAction(recoveryMeta(REMEDIATION_URL), "WAITING_FOR_INPUT");
const link = screen.getByTestId("remediation-link") as HTMLAnchorElement;
expect(link.href).toBe(REMEDIATION_URL);
expect(screen.getByTestId(RESUME_TEST_ID)).toBeTruthy();
});
it("renders no link for an invalid remediation URL", () => {
renderAction(
recoveryMeta("https://evil.example.com/workspace/wrk_123/go"),
"WAITING_FOR_INPUT",
);
expect(screen.queryByTestId("remediation-link")).toBeNull();
expect(screen.getByTestId(RESUME_TEST_ID)).toBeTruthy();
});
});
@@ -22,6 +22,7 @@ import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useTaskRemoval } from "@/hooks/use-task-removal";
import { deleteTask } from "@/lib/api/domains/kanban-api";
import { AuthMethodsPanel, GenericAuthPanel } from "./auth-methods-panel";
import { RemediationLink } from "@/components/task/remediation-link";
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
import type { Message, TaskSessionState } from "@/lib/types/http";
import type { MessageAction, RecoveryAuthMethod } from "@/components/task/chat/types";
@@ -51,6 +52,7 @@ type ActionMeta = {
provider_name?: string;
model_id?: string;
reset_at?: string;
remediation_url?: string;
};
function isSessionActive(state?: TaskSessionState) {
@@ -360,6 +362,7 @@ function ActionMessageDetails({
const errorOutput = metadata.error_output || technicalDetails;
return (
<>
{metadata.remediation_url && <RemediationLink url={metadata.remediation_url} />}
{errorOutput && <TechnicalDetails>{errorOutput}</TechnicalDetails>}
{metadata.is_auth_error && metadata.auth_methods && metadata.auth_methods.length > 0 && (
<AuthMethodsPanel
@@ -0,0 +1,34 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { IconExternalLink } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { normalizeRemediationUrl } from "@/lib/remediation-url";
/**
* Shared localized external link for the allowlisted provider remediation
* destination. Defense-in-depth: the URL is re-validated at the browser edge,
* so an invalid or hostile metadata value renders nothing. The 44px touch
* target on phones matches the recovery buttons beside it.
*/
export function RemediationLink({ url, className }: { url?: string; className?: string }) {
const { t } = useTranslation();
const safe = useMemo(() => normalizeRemediationUrl(url), [url]);
if (!safe) return null;
return (
<a
href={safe}
target="_blank"
rel="noopener noreferrer"
data-testid="remediation-link"
className={cn(
"inline-flex min-h-11 items-center gap-1.5 text-xs text-primary underline underline-offset-4 cursor-pointer sm:min-h-8",
className,
)}
>
<IconExternalLink className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
{t("chat:openProviderPage")}
</a>
);
}
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
buildRunErrorsFromSessions,
liveSessionMetadataFromStore,
mergeLiveSessionMetadata,
} from "./chat-entries";
import type { TaskSession } from "@/app/office/tasks/[id]/types";
const URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
function session(overrides: Partial<TaskSession>): TaskSession {
return {
id: "s1",
agentName: "CEO",
agentRole: "agent",
state: "FAILED",
isPrimary: false,
startedAt: "2026-08-02T15:00:00Z",
errorMessage: "usage limit reached",
...overrides,
};
}
describe("buildRunErrorsFromSessions", () => {
it("preserves the remediation URL from last_agent_error metadata", () => {
const errors = buildRunErrorsFromSessions([
session({
metadata: {
last_agent_error: {
message: "usage limit reached",
remediation_url: URL,
},
},
}),
]);
expect(errors).toHaveLength(1);
expect(errors[0].remediationUrl).toBe(URL);
expect(errors[0].rawPayload).toBe("usage limit reached");
});
it("reads the camelCase form after a store round trip", () => {
const errors = buildRunErrorsFromSessions([
session({ metadata: { last_agent_error: { message: "boom", remediationUrl: URL } } }),
]);
expect(errors[0].remediationUrl).toBe(URL);
});
it("omits the URL when metadata or the field is absent", () => {
for (const s of [
session({ errorMessage: "boom" }),
session({ metadata: { last_agent_error: { message: "boom" } } }),
session({ metadata: { last_agent_error: { message: "boom", remediation_url: "" } } }),
]) {
const errors = buildRunErrorsFromSessions([s]);
expect(errors[0].remediationUrl).toBeUndefined();
}
});
it("drops an unvalidated URL before it reaches RunError", () => {
const errors = buildRunErrorsFromSessions([
session({
metadata: {
last_agent_error: {
message: "boom",
remediation_url: "https://evil.example.com/workspace/wrk_123/go",
},
},
}),
]);
expect(errors[0].remediationUrl).toBeUndefined();
});
it("skips non-FAILED sessions", () => {
const errors = buildRunErrorsFromSessions([
session({ state: "RUNNING", metadata: { last_agent_error: { remediation_url: URL } } }),
]);
expect(errors).toHaveLength(0);
});
});
describe("mergeLiveSessionMetadata", () => {
const initial = { last_agent_error: { message: "boom", remediation_url: URL } };
it("keeps the initial fetch when the store has no metadata update", () => {
expect(mergeLiveSessionMetadata(initial, undefined)).toBe(initial);
expect(mergeLiveSessionMetadata(null, undefined)).toBeNull();
});
it("preserves an explicit server-side null so cleared metadata is not resurrected", () => {
expect(mergeLiveSessionMetadata(initial, null)).toBeNull();
});
it("prefers live metadata over the initial fetch", () => {
const live = { last_agent_error: { message: "newer" } };
expect(mergeLiveSessionMetadata(initial, live)).toBe(live);
});
});
describe("liveSessionMetadataFromStore", () => {
const metadata = { last_agent_error: { message: "boom", remediation_url: URL } };
it("returns undefined when no store row exists", () => {
expect(liveSessionMetadataFromStore({}, "s1")).toBeUndefined();
});
it("treats a partial row without a metadata field as metadata-absent", () => {
// Regression: a store row without `metadata` (e.g. a summary projection)
// must NOT become explicit null, which would erase the initial fetch's
// metadata and hide the remediation link.
expect(liveSessionMetadataFromStore({ s1: {} }, "s1")).toBeUndefined();
expect(mergeLiveSessionMetadata(metadata, liveSessionMetadataFromStore({ s1: {} }, "s1"))).toBe(
metadata,
);
});
it("preserves an explicit null metadata field", () => {
expect(liveSessionMetadataFromStore({ s1: { metadata: null } }, "s1")).toBeNull();
});
it("returns the live metadata object when present", () => {
expect(liveSessionMetadataFromStore({ s1: { metadata } }, "s1")).toBe(metadata);
});
});
@@ -1,5 +1,6 @@
import type { CommentTurnContext } from "./turn-context";
import { groupSortKey, type SessionGroup } from "./session-groups";
import { normalizeRemediationUrl } from "@/lib/remediation-url";
import type {
RunError,
TaskComment,
@@ -47,7 +48,9 @@ export function buildLaterAgentReplyMap(comments: TaskComment[]): Map<string, bo
/**
* Pull RunError entries out of failed sessions. One entry per office
* session in FAILED state — the session row's error_message becomes
* the rawPayload for the chat entry's Show details.
* the rawPayload for the chat entry's Show details. The remediation URL is
* copied from the persisted last_agent_error metadata and passed through the
* browser-edge allowlist, so RunError never carries an unvalidated string.
*/
export function buildRunErrorsFromSessions(sessions: TaskSession[]): RunError[] {
const errors: RunError[] = [];
@@ -60,11 +63,49 @@ export function buildRunErrorsFromSessions(sessions: TaskSession[]): RunError[]
agentProfileId: s.agentProfileId,
rawPayload: s.errorMessage ?? "",
failedAt,
remediationUrl:
normalizeRemediationUrl(remediationUrlFromSessionMetadata(s.metadata)) ?? undefined,
});
}
return errors;
}
export function mergeLiveSessionMetadata(
base: Record<string, unknown> | null | undefined,
live: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null | undefined {
// Tri-state: `live` is undefined when the store has no metadata update for
// this session (keep the initial fetch), and explicit null is preserved so
// a server-side metadata clear cannot resurrect stale initial metadata.
return live === undefined ? base : live;
}
/**
* Live metadata for one session from the taskSessions store. Returns
* undefined both when no store row exists and when an existing row carries no
* `metadata` field (a partial row) — in both cases the initial REST fetch
* stays authoritative. Explicit `null` is preserved because the server uses
* it to clear metadata.
*/
export function liveSessionMetadataFromStore(
items: Record<string, { metadata?: Record<string, unknown> | null } | undefined>,
sessionId: string,
): Record<string, unknown> | null | undefined {
const row = items[sessionId];
if (row === undefined || row.metadata === undefined) {
return undefined;
}
return row.metadata;
}
function remediationUrlFromSessionMetadata(metadata: Record<string, unknown> | null | undefined) {
const lastError = metadata?.last_agent_error;
if (!lastError || typeof lastError !== "object") return undefined;
const record = lastError as Record<string, unknown>;
const raw = record.remediation_url ?? record.remediationUrl;
return typeof raw === "string" && raw !== "" ? raw : undefined;
}
export type MergeChatEntriesArgs = {
comments: TaskComment[];
timeline: TimelineEvent[];
@@ -13,6 +13,7 @@ import { useAppStore } from "@/components/state-provider";
import { getWebSocketClient } from "@/lib/ws/connection";
import { formatRelativeTime } from "@/lib/utils";
import { AgentAvatar } from "@/app/office/components/agent-avatar";
import { RemediationLink } from "@/components/task/remediation-link";
import type { RunError } from "@/app/office/tasks/[id]/types";
import { useTranslation } from "react-i18next";
@@ -85,7 +86,8 @@ export function RunErrorEntry({ taskId, error }: RunErrorEntryProps) {
</CollapsibleContent>
</Collapsible>
)}
<div className="mt-2 flex items-center gap-2">
<div className="mt-2 flex items-center gap-2 flex-wrap">
<RemediationLink url={error.remediationUrl} />
<Button
variant="outline"
size="sm"
@@ -0,0 +1,92 @@
import { test, expect } from "../../fixtures/office-fixture";
const REMEDIATION_URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
test.describe("Office run-error remediation link", () => {
test("renders the allowlisted link on the failed-session chat entry", async ({
testPage,
apiClient,
officeSeed,
}) => {
const task = await apiClient.createTask(officeSeed.workspaceId, "Office remediation link", {
workflow_id: officeSeed.workflowId,
});
await apiClient.rawRequest("PATCH", `/api/v1/office/tasks/${task.id}`, {
assignee_agent_profile_id: officeSeed.agentId,
});
await apiClient.seedTaskSession(task.id, {
state: "FAILED",
agentProfileId: officeSeed.agentId,
completedAt: "2026-08-07T15:15:44Z",
metadata: {
last_agent_error: {
message: "AI_APICallError: 5-hour usage limit reached.",
occurred_at: "2026-08-07T15:15:44Z",
remediation_url: REMEDIATION_URL,
},
},
});
await testPage.goto(`/office/tasks/${task.id}`);
await expect(testPage.getByRole("heading", { name: "Office remediation link" })).toBeVisible({
timeout: 10_000,
});
const link = testPage.getByTestId("remediation-link");
await expect(link).toBeVisible({ timeout: 10_000 });
await expect(link).toHaveAttribute("href", REMEDIATION_URL);
await expect(link).toHaveAttribute("target", "_blank");
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
// The destination exists only on the link — never as visible text.
await expect(testPage.getByTestId("task-chat-root")).not.toContainText("opencode.ai");
});
test("renders no link for invalid or absent remediation metadata", async ({
testPage,
apiClient,
officeSeed,
}) => {
for (const [title, metadata] of [
[
"Office invalid link",
{
last_agent_error: {
message: "provider rejected the request.",
occurred_at: "2026-08-07T15:15:44Z",
remediation_url: "https://evil.example.com/x",
},
},
],
[
"Office no link",
{
last_agent_error: {
message: "AI_APICallError: provider rejected the request.",
occurred_at: "2026-08-07T15:15:44Z",
},
},
],
] as const) {
const task = await apiClient.createTask(officeSeed.workspaceId, title, {
workflow_id: officeSeed.workflowId,
});
await apiClient.rawRequest("PATCH", `/api/v1/office/tasks/${task.id}`, {
assignee_agent_profile_id: officeSeed.agentId,
});
await apiClient.seedTaskSession(task.id, {
state: "FAILED",
agentProfileId: officeSeed.agentId,
completedAt: "2026-08-07T15:15:44Z",
metadata,
});
await testPage.goto(`/office/tasks/${task.id}`);
await expect(testPage.getByRole("heading", { name: title })).toBeVisible({ timeout: 10_000 });
await expect(testPage.getByTestId("run-error-resume-button")).toBeVisible({
timeout: 10_000,
});
await expect(testPage.getByTestId("remediation-link")).toHaveCount(0);
}
});
});
@@ -0,0 +1,95 @@
// Filename starts with "mobile-" so this runs on the mobile-chrome project.
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { SessionPage } from "../../pages/session-page";
const REMEDIATION_URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
async function seedRecoveryWithLink(apiClient: ApiClient, seedData: SeedData) {
const task = await apiClient.createTask(
seedData.workspaceId,
"Mobile OpenCode remediation link",
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
},
);
const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
state: "FAILED",
completedAt: "2026-08-07T15:15:44Z",
});
await apiClient.seedSessionMessage(sessionId, {
type: "status",
content: "OpenCode stopped responding after the provider rejected the stream.",
metadata: {
recovery_actions: true,
failure_kind: "provider_quota_limited",
provider_name: "OpenCode",
error_output: "5-hour usage limit reached. Resets in 4hr 19min.",
remediation_url: REMEDIATION_URL,
actions: [
{
type: "archive_task",
label: "Archive task",
test_id: "provider-quota-archive-button",
},
],
},
});
return task;
}
test("keeps the OpenCode recovery link touch-safe on mobile", async ({
testPage,
apiClient,
seedData,
prCapture,
}, testInfo) => {
const task = await seedRecoveryWithLink(apiClient, seedData);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const recovery = session.activeChat().getByTestId("provider-quota-recovery");
await expect(recovery).toBeVisible();
const link = recovery.getByTestId("remediation-link");
await expect(link).toBeVisible();
await expect(link).toHaveAttribute("href", REMEDIATION_URL);
await expect(link).toHaveAttribute("target", "_blank");
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
// 44px minimum touch target on phones, like the recovery buttons beside it.
const box = await link.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
// Keyboard reachable via the native anchor focus path.
await link.focus();
await expect(link).toBeFocused();
// The sanitized message and technical details never carry the URL.
await expect(recovery).not.toContainText("opencode.ai");
await expect(recovery).not.toContainText("wrk_");
const technicalOutput = recovery.locator("details").locator("pre");
await recovery.getByText("Technical details", { exact: true }).tap();
await expect(technicalOutput).toBeVisible();
await expect(technicalOutput).not.toContainText("https://opencode.ai");
await expect(technicalOutput).not.toContainText("wrk_");
await expect(technicalOutput).not.toContainText("ses_");
await expect(recovery.getByTestId("provider-quota-archive-button")).toBeInViewport();
await assertNoDocumentHorizontalOverflow(testPage, "mobile provider remediation link");
await testPage.screenshot({
path: testInfo.outputPath("provider-remediation-link-mobile.png"),
fullPage: true,
});
await prCapture.screenshot("provider-remediation-link-mobile", {
caption: "OpenCode recovery link stays touch-safe on mobile",
fullPage: true,
});
});
@@ -0,0 +1,172 @@
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { SessionPage } from "../../pages/session-page";
const REMEDIATION_URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
const RECOVERY_MESSAGE =
"OpenCode stopped responding after the provider rejected the stream. No URL should ever appear in this message.";
async function seedRecoveryWithLink(apiClient: ApiClient, seedData: SeedData) {
const task = await apiClient.createTask(seedData.workspaceId, "OpenCode remediation link", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
state: "FAILED",
completedAt: "2026-08-07T15:15:44Z",
});
await apiClient.seedSessionMessage(sessionId, {
type: "status",
content: RECOVERY_MESSAGE,
metadata: {
recovery_actions: true,
failure_kind: "provider_quota_limited",
provider_name: "OpenCode",
error_output: "5-hour usage limit reached. Resets in 4hr 19min.",
remediation_url: REMEDIATION_URL,
actions: [
{
type: "archive_task",
label: "Archive task",
test_id: "provider-quota-archive-button",
},
{
type: "delete_task",
label: "Delete task",
test_id: "provider-quota-delete-button",
variant: "destructive",
},
],
},
});
return task;
}
test("renders the allowlisted OpenCode recovery link without leaking it into the message", async ({
testPage,
apiClient,
seedData,
prCapture,
}, testInfo) => {
const task = await seedRecoveryWithLink(apiClient, seedData);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const recovery = session.activeChat().getByTestId("provider-quota-recovery");
await expect(recovery).toBeVisible();
const link = recovery.getByTestId("remediation-link");
await expect(link).toBeVisible();
await expect(link).toHaveAttribute("href", REMEDIATION_URL);
await expect(link).toHaveAttribute("target", "_blank");
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
await expect(link).toContainText("Open provider page");
// The URL must never leak into the card, the sanitized message, or details.
await expect(recovery).not.toContainText("opencode.ai");
await expect(recovery).not.toContainText("wrk_");
await expect(recovery).toContainText("5-hour usage limit reached");
// The link is keyboard-reachable as a native anchor.
await link.focus();
await expect(link).toBeFocused();
const technicalOutput = recovery.locator("details").locator("pre");
await recovery.getByText("Technical details", { exact: true }).click();
await expect(technicalOutput).toBeVisible();
await expect(technicalOutput).toContainText("5-hour usage limit reached");
await expect(technicalOutput).not.toContainText("https://opencode.ai");
await expect(technicalOutput).not.toContainText("wrk_");
await expect(technicalOutput).not.toContainText("ses_");
// Existing recovery actions stay intact beside the link.
await expect(recovery.getByTestId("provider-quota-archive-button")).toBeVisible();
await expect(recovery.getByTestId("provider-quota-delete-button")).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "provider remediation link");
await testPage.screenshot({
path: testInfo.outputPath("provider-remediation-link-desktop.png"),
fullPage: true,
});
await prCapture.screenshot("provider-remediation-link-desktop", {
caption: "OpenCode recovery link renders separately from the sanitized message",
fullPage: true,
});
});
test("keeps the short-error fallback when no remediation URL is present", async ({
testPage,
apiClient,
seedData,
}) => {
// Mirrors OpenCode 1.18.5's ACP service-failure path: only the short safe
// message arrives, so Kandev must show it without inventing a link.
const task = await apiClient.createTask(seedData.workspaceId, "OpenCode short error fallback", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
state: "FAILED",
completedAt: "2026-08-07T15:15:44Z",
});
await apiClient.seedSessionMessage(sessionId, {
type: "status",
content: "AI_APICallError: provider rejected the request.",
metadata: {
recovery_actions: true,
actions: [
{
type: "archive_task",
label: "Archive task",
test_id: "provider-quota-archive-button",
},
],
},
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// The generic recovery card keeps the short message and offers no link.
await expect(session.activeChat()).toContainText("AI_APICallError");
await expect(session.activeChat().getByTestId("remediation-link")).toHaveCount(0);
await expect(session.activeChat().getByTestId("provider-quota-archive-button")).toBeVisible();
});
test("renders the remediation link inside the persistent last-agent-error notice", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "Last agent error link", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
metadata: {
last_agent_error: {
message: "AI_APICallError: 5-hour usage limit reached.",
occurred_at: "2026-08-07T15:15:44Z",
remediation_url: REMEDIATION_URL,
},
},
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const notice = testPage.getByTestId("last-agent-error-notice");
await expect(notice).toBeVisible({ timeout: 15_000 });
const link = notice.getByTestId("remediation-link");
await expect(link).toBeVisible();
await expect(link).toHaveAttribute("href", REMEDIATION_URL);
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
await expect(notice).not.toContainText("opencode.ai/workspace");
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { normalizeRemediationUrl } from "./remediation-url";
const WANT = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
describe("normalizeRemediationUrl", () => {
it("accepts the allowlisted OpenCode route", () => {
expect(normalizeRemediationUrl(WANT)).toBe(WANT);
});
it.each([
null,
undefined,
"",
42,
{},
[],
"not a url",
"http://opencode.ai/workspace/wrk_123/go",
"https://opencode.example.com/workspace/wrk_123/go",
"https://opencode.ai/workspace/wrk_123",
"https://opencode.ai/workspace/wrk_123/go/",
"https://opencode.ai/workspace/wrk_123/other",
"https://opencode.ai/workspace/wrk_123/go?source=email",
"https://opencode.ai/workspace/wrk_123/go#fragment",
"https://user:pass@opencode.ai/workspace/wrk_123/go",
"https://opencode.ai:443/workspace/wrk_123/go",
"https://opencode.ai/workspace/../go",
"https://opencode.ai/workspace/../workspace/wrk_123/go",
"https://opencode.ai/workspace/wrk_123/..%2Fgo",
"https://opencode.ai/workspace/%2e%2e/workspace/wrk_123/go",
"https://opencode.ai/workspace/wrk_123/go?",
"https://opencode.ai/workspace/wrk_123/go#",
"https://opencode.ai/workspace/wrk_123/go?#",
"https://opencode.ai/workspace/wrk_123/go extra",
"https://opencode.ai/workspace/" + "w".repeat(200) + "/go",
`https://opencode.ai/workspace/${"w".repeat(129)}/go`,
"https://opencode.ai/workspace/wrk_123/go.",
])("rejects invalid input %j", (raw) => {
expect(normalizeRemediationUrl(raw)).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Browser-side defense-in-depth validation for the provider remediation URL
* contract (ADR-2026-08-07-allowlisted-provider-action-links). The backend
* already allowlists the URL; this mirror rejects anything unexpected before
* it becomes an href, so a malformed or hostile metadata value can never
* render as a navigable link.
*
* The raw string must already be the exact allowlisted route. Parsing is a
* secondary validation/error step — never evidence that the route is
* allowlisted — so literal or percent-encoded traversal cannot normalize
* into a passing shape.
*/
const REMEDIATION_URL_MAX_LENGTH = 256;
const REMEDIATION_URL_PATTERN = /^https:\/\/opencode\.ai\/workspace\/[A-Za-z0-9_-]{1,128}\/go$/;
export function normalizeRemediationUrl(raw: unknown): string | null {
if (typeof raw !== "string" || raw.length === 0 || raw.length > REMEDIATION_URL_MAX_LENGTH) {
return null;
}
if (!REMEDIATION_URL_PATTERN.test(raw)) {
return null;
}
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
// Defense in depth: the parsed shape must agree with the raw allowlist.
if (url.protocol !== "https:" || url.hostname !== REMEDIATION_URL_HOST) {
return null;
}
if (
url.username !== "" ||
url.password !== "" ||
url.search !== "" ||
url.hash !== "" ||
url.port !== ""
) {
return null;
}
return url.href;
}
const REMEDIATION_URL_HOST = "opencode.ai";
@@ -32,6 +32,40 @@ describe("readLastAgentError", () => {
});
});
it("reads the remediation_url field from snake_case and camelCase metadata", () => {
const url = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
expect(
readLastAgentError({
last_agent_error: {
message: AGENT_ERROR_MESSAGE,
remediation_url: url,
},
}),
).toEqual({
message: AGENT_ERROR_MESSAGE,
remediationUrl: url,
});
expect(
readLastAgentError({
last_agent_error: {
message: AGENT_ERROR_MESSAGE,
remediationUrl: url,
},
}),
).toEqual({
message: AGENT_ERROR_MESSAGE,
remediationUrl: url,
});
});
it("omits remediationUrl when the metadata carries no URL", () => {
expect(
readLastAgentError({
last_agent_error: { message: AGENT_ERROR_MESSAGE, remediation_url: "" },
}),
).toEqual({ message: AGENT_ERROR_MESSAGE });
});
it("reads camelCase metadata after a store round trip", () => {
expect(
readLastAgentError({
+4
View File
@@ -4,6 +4,8 @@ export type LastAgentError = {
message: string;
occurredAt?: string;
agentExecutionId?: string;
/** Adapter-validated provider remediation URL; never derived from prose. */
remediationUrl?: string;
dismissedAt?: string;
};
@@ -55,6 +57,8 @@ export function readLastAgentError(metadata: Record<string, unknown> | null | un
occurredAt: readOptionalString(record.occurred_at) ?? readOptionalString(record.occurredAt),
agentExecutionId:
readOptionalString(record.agent_execution_id) ?? readOptionalString(record.agentExecutionId),
remediationUrl:
readOptionalString(record.remediation_url) ?? readOptionalString(record.remediationUrl),
} satisfies LastAgentError;
}
+1
View File
@@ -56,6 +56,7 @@
"noAgentsAvailable": "No agents available",
"noRepositoriesAvailableInWorkspace": "No repositories available in this workspace",
"noRepository": "No repository",
"openProviderPage": "Open provider page",
"optional": "(optional)",
"providerQuotaModel": "Model: {{model}}",
"providerQuotaProviderFallback": "Provider",
+1
View File
@@ -56,6 +56,7 @@
"noAgentsAvailable": "Ńō àĝēńţś àvàĩĺàƀĺē",
"noRepositoriesAvailableInWorkspace": "Ńō ŕēƥōśĩţōŕĩēś àvàĩĺàƀĺē ĩń ţĥĩś ŵōŕķśƥàćē",
"noRepository": "Ńō ŕēƥōśĩţōŕŷ",
"openProviderPage": "Ōƥēń ƥŕōvĩďēŕ ƥàĝē",
"optional": "(ōƥţĩōńàĺ)",
"providerQuotaModel": "Ḿōďēĺ: {{model}}",
"providerQuotaProviderFallback": "Ƥŕōvĩďēŕ",
+2 -1
View File
@@ -119,5 +119,6 @@
"composerSteerPlaceholder": "Enviar agora — entregue ao turno em curso",
"unknownError": "Erro desconhecido",
"useWorkspaceDefault": "Usar a pré-definição da área de trabalho...",
"useWorkspaceDefaultOption": "Usar a pré-definição da área de trabalho"
"useWorkspaceDefaultOption": "Usar a pré-definição da área de trabalho",
"openProviderPage": "Abrir a página do fornecedor"
}
+2 -1
View File
@@ -119,5 +119,6 @@
"composerSteerPlaceholder": "立即发送 — 将送入正在进行的回合",
"unknownError": "未知错误",
"useWorkspaceDefault": "使用工作区默认值…",
"useWorkspaceDefaultOption": "使用工作区默认值"
"useWorkspaceDefaultOption": "使用工作区默认值",
"openProviderPage": "打开服务商页面"
}
@@ -0,0 +1,72 @@
# ADR-2026-08-07-allowlisted-provider-action-links: Allowlisted Provider Action Links
**Status:** accepted
**Date:** 2026-08-07
**Area:** backend, frontend, protocol, security
## Context
The OpenCode TUI can render a provider failure with a workspace-specific URL,
while OpenCode's ACP service-failure response currently carries only a short,
safe message. Kandev's existing stderr diagnostic boundary intentionally strips
URLs and workspace identifiers, so the recovery card cannot offer the same
actionable destination even when a managed structured diagnostic contains it.
The existing terminal-diagnostics decision correctly rejects raw stderr and
private OpenCode log files. A narrowly scoped, validated link is useful, but it
changes the boundary that currently excludes provider workspace identifiers and
must not turn arbitrary provider text into browser navigation.
## Decision
Kandev may carry an optional `remediation_url` alongside the normalized
`ProviderError`, the persisted `LastAgentError`, and recovery-message metadata
only when an adapter-specific validator accepts the exact OpenCode route
`https://opencode.ai/workspace/<safe-workspace-id>/go`.
The validator requires HTTPS, the exact host, the exact path shape, a bounded
workspace identifier, no userinfo, query, or fragment, and a bounded complete
URL. The URL is extracted before message sanitization, while the provider
message continues to remove all URLs and identifier-bearing content. Raw stderr,
raw ACP error objects, private log files, and arbitrary URLs never cross the
agentctl boundary or reach the browser.
The adapter accepts the link from a structured ACP error field when OpenCode
provides one and from a correlated structured OpenCode stderr record. Kandev
does not reconstruct a link from a TUI-only error or read OpenCode's private log
directory. OpenCode 1.18.5 therefore keeps the existing short-error fallback
until an upstream ACP or managed-stderr contract emits the optional field.
Kanban recovery messages, the persistent last-agent-error notice, and Office
failed-session entries render the validated destination as a localized external
link. They share the same safe field and preserve the existing recovery actions
and sanitized technical details.
## Consequences
Users can open the provider's actionable recovery page when the agent transport
actually supplied it, including after a reload and on the Office surface. The
new field is deliberately useful rather than a general log-exposure mechanism.
The URL contains a provider workspace identifier, so the allowlist, bounds, and
frontend defense-in-depth check become compatibility and security contracts.
The live fix for the reported OpenCode 1.18.5 case depends on an upstream
change; without that change Kandev cannot recover information absent from ACP,
stdout, and managed stderr.
## Alternatives Considered
- **Expose raw stderr or tail OpenCode's private log:** rejected because those
sources contain unrelated sessions, paths, URLs, and potentially sensitive
provider data, and violate the existing diagnostic boundary.
- **Copy the entire ACP error object into the chat:** rejected because ACP
errors are not a stable safe UI contract and may contain raw request or
provider data.
- **Allow any HTTPS URL emitted by a provider:** rejected because a provider
message is untrusted input and arbitrary navigation creates a phishing and
data-disclosure surface.
- **Keep stripping every URL:** rejected because it needlessly loses a
validated, user-actionable destination when the transport provides one.
- **Wait for OpenCode alone:** rejected as the only Kandev change; Kandev can
prepare a safe forward-compatible contract and surface structured stderr
links while upstream conformance is pending.
+1
View File
@@ -112,6 +112,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-02-explicit-user-cancel-completion | [Explicit User Cancellation May Complete a Workflow Step](2026-08-02-explicit-user-cancel-completion.md) | accepted | workflow | 2026-08-02 |
| 2026-08-02-single-owner-agent-task-titles | [Assign Agent Task Titles to One Session](2026-08-02-single-owner-agent-task-titles.md) | accepted | backend, frontend, protocol, workflow | 2026-08-02 |
| 2026-08-02-agent-terminal-diagnostics-over-stderr | [Capture Agent Terminal Diagnostics From Managed Stderr](2026-08-02-agent-terminal-diagnostics-over-stderr.md) | accepted | backend, frontend, protocol, security | 2026-08-02 |
| 2026-08-07-allowlisted-provider-action-links | [Allowlisted Provider Action Links](2026-08-07-allowlisted-provider-action-links.md) | accepted | backend, frontend, protocol, security | 2026-08-07 |
| 2026-08-02-class-aware-git-subprocess-admission | [Class-Aware Git Subprocess Admission](2026-08-02-class-aware-git-subprocess-admission.md) | accepted | backend, agentctl, observability | 2026-08-02 |
| 2026-08-02-isolate-replaceable-session-stream-traffic | [Isolate Replaceable Session Stream Traffic](2026-08-02-isolate-replaceable-session-stream-traffic.md) | accepted | backend, frontend, protocol | 2026-08-02 |
| 2026-08-03-backend-owned-cancellation-progress | [Keep Cancellation Progress Backend Owned](2026-08-03-backend-owned-cancellation-progress.md) | accepted | backend, frontend, protocol | 2026-08-03 |
@@ -0,0 +1,234 @@
---
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.
@@ -0,0 +1,70 @@
---
id: "01-normalize-action-url"
title: "Normalize OpenCode action URLs"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 01: Normalize OpenCode Action URLs
## Acceptance
- `ProviderError` has an optional `remediation_url` field with the existing
bounded/sanitized contract.
- The OpenCode parser extracts only
`https://opencode.ai/workspace/<safe-workspace-id>/go`; it removes the URL and
identifiers from `message` and never returns arbitrary URLs.
- Future structured ACP error data may supply `action_url`, but only the same
validator accepts it; missing, malformed, wrong-host, query-bearing, and
oversized values produce no URL.
- Existing correlation, prompt races, stderr projection, and generic error
behavior remain unchanged.
## Verification
```text
cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api -run 'Test(OpenCode|ProviderError|Prompt.*Error|HandleWSPrompt)' -count=1
```
Use TDD with the observed URL as a fixture and table-driven rejection cases.
Include a structured ACP error fixture without requiring a live OpenCode
process. Assert no raw URL or workspace identifier appears in the sanitized
message or generic stderr projection.
## Files likely touched
- `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`
## Dependencies and risks
None inside Kandev. The live current-version path still depends on OpenCode
emitting `action_url` through ACP or structured stderr. Do not read its private
log directory or widen the parser to arbitrary prose.
## Results
Implemented 2026-08-07.
- `streams.ProviderError.RemediationURL` added (`remediation_url,omitempty`);
new `ProviderErrorSourceOpenCodeACP` source for the structured ACP path.
- Shared allowlist `NormalizeOpenCodeActionURL` accepts only
`https://opencode.ai/workspace/<safe-workspace-id>/go` (exact host, HTTPS,
no userinfo/query/fragment/port/percent-encoding, bounded identifier and
URL). Stderr parser captures it from a dedicated `action_url` field or from
the provider error text before sanitization; `ProviderErrorFromError`
projects a future ACP `RequestError` carrying structured `action_url`, and
the message runs through the existing sanitizer so no URL or workspace
identifier leaks.
- Table-driven tests cover the observed fixture URL, the explicit field,
wrong hosts/schemes/paths, query/fragment/userinfo/port, traversal IDs,
oversized values, malformed ACP data, and wrapped generic errors.
Verification: `go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api -run 'Test(OpenCode|ProviderError|Prompt.*Error|HandleWSPrompt)' -count=1` — passed (plus the full acp package suite, 787 tests).
@@ -0,0 +1,64 @@
---
id: "02-persist-recovery-link"
title: "Persist recovery links"
status: done
wave: 2
depends_on: ["01-normalize-action-url"]
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 02: Persist Recovery Links
## Acceptance
- `LastAgentError` persists a validated `remediation_url` without changing the
plain `TaskSession.error_message` contract.
- Generic recoverable errors and quota-specialized errors both carry the link
when the normalized provider diagnostic has one.
- The existing lifecycle generation guards still select the winning provider
error; stale or invalid diagnostics cannot overwrite the persisted link.
- `session.state_changed` includes the structured metadata so connected Office
clients receive the same value as a reload.
## Verification
```text
cd apps/backend && go test ./internal/task/models ./internal/orchestrator ./internal/agent/runtime/lifecycle -run 'Test(LastAgentError|HandleRecoverableFailure|CreateRecoveryStatusMessage|.*ProviderError)' -count=1
```
Assert the URL is present only in the dedicated field, while `message`,
`error_output`, `error_message`, and generic logs remain URL-free.
## Files likely touched
- `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`
- relevant lifecycle/session-state event tests
## Dependencies and risks
Task 01. Reuse the existing `ProviderError` propagation; do not create a second
diagnostic event or a parallel session state. Preserve local dismissal and
last-error stamping behavior.
## Results
Implemented 2026-08-07.
- `models.LastAgentError.RemediationURL` persisted under
`SessionMetaKeyLastAgentError`; plain `TaskSession.error_message` contract
unchanged and URL-free.
- `providerRemediationURL` copies only the adapter-validated field from the
normalized `ProviderError`; `createRecoveryStatusMessage` carries
`remediation_url` in the action-message metadata independently of quota
classification (ACP-sourced diagnostics are not quota-classified but still
show the link). The `task_session.error_changed` event carries it, and
`session.state_changed` includes it via the persisted `session_metadata`.
- Tests: quota + generic cards carry the URL; nil/invalid diagnostics produce
no metadata key; `handleRecoverableFailure` persists the URL and keeps
`error_message` URL-free; absent diagnostics omit the field.
Verification: `go test ./internal/task/models ./internal/orchestrator ./internal/agent/runtime/lifecycle -run 'Test(LastAgentError|HandleRecoverableFailure|CreateRecoveryStatusMessage|.*ProviderError)' -count=1` — passed; full orchestrator (3094 tests) and lifecycle (1962 tests) suites passed.
@@ -0,0 +1,76 @@
---
id: "03-render-recovery-link"
title: "Render recovery links"
status: done
wave: 3
depends_on: ["02-persist-recovery-link"]
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 03: Render Recovery Links
## Acceptance
- Kanban action messages and persistent last-agent-error notices render a
localized external link when `remediation_url` is present.
- Office failed-session entries render the same link from initial API data and
live `session.state_changed` metadata.
- Browser-side defense-in-depth validation rejects invalid URLs before creating
an `href`; valid links use `target="_blank"` and
`rel="noopener noreferrer"`.
- The existing sanitized message, collapsed technical details, recovery
buttons, keyboard navigation, and mobile chat scroll owner remain intact.
## Verification
```text
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
```
Add focused tests for generic/quota cards, dismissed notices, Office mapping,
invalid metadata, translated labels, and the minimum 44px phone affordance.
## Files likely touched
- `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`
- focused Office/chat tests
- `apps/web/src/locales/en/task.json`
- `apps/web/src/locales/en/chat.json`
- matching pseudo-locale files
## Dependencies and risks
Task 02. The external URL is a user-visible security boundary; keep validation
at the backend and browser edges. Do not render the raw diagnostic message as
HTML or use translated text for control flow.
## Results
Implemented 2026-08-07.
- `lib/remediation-url.ts` mirrors the backend allowlist at the browser edge
(host, scheme, path shape, bounded identifier, no port/query/fragment/
userinfo); `components/task/remediation-link.tsx` renders the localized
`chat:openProviderPage` link with `target="_blank"`, `rel="noopener noreferrer"`
and a 44px phone touch target, and returns null for invalid input.
- Wired into `ActionMessageDetails` (generic + quota recovery cards), the
persistent `LastAgentErrorNotice` (`readLastAgentError` reads
`remediation_url`/`remediationUrl`), and Office `RunErrorEntry` via
`buildRunErrorsFromSessions`; the office page maps `metadata` from the API
and merges live `session.state_changed` metadata.
- Unit tests: validator table, action-message link cases (quota/generic/
invalid), notice link cases, session-last-agent-error parsing, and
`buildRunErrorsFromSessions` preservation.
Verification: targeted vitest run (106 tests) and the full web unit suite
(1236 files / 9669 tests) passed; `typecheck`, `i18n:check`, `i18n:ratchet`
clean (en/pt-pt/zh-cn catalogs + regenerated pseudo).
@@ -0,0 +1,72 @@
---
id: "04-verify-actionable-error-links"
title: "Verify actionable error links"
status: done
wave: 4
depends_on: ["02-persist-recovery-link", "03-render-recovery-link"]
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 04: Verify Actionable Error Links
## Acceptance
- Backend tests prove the observed OpenCode route is preserved only as
`remediation_url`, and all rejected URL classes are omitted.
- Seeded desktop E2E proves the recovery link, short sanitized message, and
existing recovery actions; the no-link short-ACP fallback is covered.
- Seeded `mobile-chrome` E2E proves keyboard/touch reachability, 44px controls,
bounded details, and no horizontal overflow.
- Tests never invoke a real quota failure, read a developer's OpenCode logs, or
add a production-only test hook.
## Verification
```text
cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api ./internal/agent/runtime/lifecycle ./internal/orchestrator -run 'Test(OpenCode|ProviderError|RecoverableFailure|RecoveryStatus)' -count=1
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
```
Inspect the phone viewport in addition to assertions and record exact results
in the plan's Verification Results section.
## Files likely touched
- `apps/web/e2e/tests/session/provider-remediation-link.spec.ts`
- `apps/web/e2e/tests/session/mobile-provider-remediation-link.spec.ts`
- existing E2E page/fixture helpers only if a reusable seeded metadata path is
required
- backend lifecycle/orchestrator regression tests
## Dependencies and risks
Tasks 02 and 03. Seed the safe contract with a non-sensitive test workspace
identifier; assert no invalid URL reaches the DOM. A seeded UI test proves
rendering, not OpenCode upstream emission, so preserve the explicit external
dependency in the final report.
## Results
Implemented 2026-08-07.
- Desktop `tests/session/provider-remediation-link.spec.ts` (3 tests): link
href/target/rel + keyboard focus, sanitized message and technical details
stay URL/identifier-free, existing recovery actions intact, no horizontal
overflow; short-error fallback renders no link; persistent
last-agent-error notice renders the link.
- Mobile `tests/session/mobile-provider-remediation-link.spec.ts`
(mobile-chrome): 44px touch target, focus reachability, URL-free details,
in-viewport actions, no horizontal overflow.
- Office `tests/office/provider-remediation-link.spec.ts` (2 tests):
RunErrorEntry renders the link from seeded `last_agent_error` metadata;
invalid or absent metadata renders no link.
- All seeds inject only safe `remediation_url` metadata; no real provider,
quota exhaustion, or OpenCode log access.
Verification: `cd apps/backend && go test ... -run 'Test(OpenCode|ProviderError|RecoverableFailure|RecoveryStatus)' -count=1` — passed; desktop + mobile-chrome + office E2E — all passed (run via `pnpm e2e:run --no-build`).
External dependency preserved: OpenCode must emit `action_url` for the live
1.18.5 failure to show its TUI URL; Kandev shows the short error until then.
+43 -2
View File
@@ -1,7 +1,7 @@
---
status: approved
created: 2026-07-29
updated: 2026-08-02
updated: 2026-08-07
owner: Kandev
---
@@ -11,11 +11,13 @@ Decisions:
- [ADR-2026-07-29-agent-stall-user-controlled-recovery](../../decisions/2026-07-29-agent-stall-user-controlled-recovery.md)
- [ADR-2026-08-02-agent-terminal-diagnostics-over-stderr](../../decisions/2026-08-02-agent-terminal-diagnostics-over-stderr.md)
- [ADR-2026-08-07-allowlisted-provider-action-links](../../decisions/2026-08-07-allowlisted-provider-action-links.md)
Implementation plans:
- [Agent stall recovery](../../plans/agent-stall-recovery/plan.md)
- [OpenCode terminal error surfacing](../../plans/opencode-terminal-error-surfacing/plan.md)
- [OpenCode actionable error links](../../plans/opencode-actionable-error-links/plan.md)
## Why
@@ -79,6 +81,27 @@ mere inactivity so a failed turn is not presented as healthy work.
proof of failure. The prompt continues and the advisory inactivity recovery
remains available.
### Allowlisted actionable diagnostics
- A validated OpenCode remediation URL may cross the agentctl boundary as a
separate `remediation_url` field. It is not appended to the sanitized
provider message and is never sourced from an arbitrary raw error string.
- The only accepted URL shape is HTTPS on the exact `opencode.ai` host with a
path of `/workspace/<safe-workspace-id>/go`, with no userinfo, query, or
fragment. The workspace identifier and complete URL are bounded before they
are retained.
- Kandev extracts this field from a structured ACP error field when OpenCode
supplies one, or from a correlated structured OpenCode stderr record. If the
source does not carry the URL, Kandev keeps the existing short error and does
not attempt to reconstruct it from private OpenCode files.
- Kanban recovery cards, the persisted last-agent-error notice, and Office's
failed-session entry expose the same safe destination as a localized,
keyboard- and touch-accessible external link. The sanitized message and
collapsed technical details remain unchanged.
- A missing, malformed, untrusted, or unsupported URL produces no link and no
URL-bearing fallback text. Existing recovery actions and generic error copy
remain available.
## Diagnostic contract
The internal normalized provider diagnostic contains only:
@@ -91,6 +114,7 @@ The internal normalized provider diagnostic contains only:
| `message` | Sanitized provider message with URLs and identifiers removed |
| `occurred_at` | Parsed diagnostic timestamp |
| `reset_at` | Derived by adding a relative reset duration to `occurred_at`; absolute reset timestamps are not accepted in this version |
| `remediation_url` | Optional, exact-host OpenCode action URL accepted by the adapter-specific validator |
The persisted recovery-message metadata uses
`failure_kind: provider_quota_limited`, plus the safe model and reset fields,
@@ -120,6 +144,10 @@ sanitized diagnostic message for the collapsed technical-details surface.
- Advisory stall notices retain their existing persisted-message behavior.
- Sanitized provider failures use the existing session error and recovery
message persistence. They survive reloads like other recoverable failures.
- A validated `remediation_url` is persisted only in the structured recovery
metadata and remains available after reloads and through the Office
`session.state_changed` snapshot. It is omitted from generic error text and
raw stderr.
- Raw agent stderr remains a bounded, executor-local in-memory diagnostic. It
is not added to session metadata, messages, debug exports, or durable logs by
this feature.
@@ -164,6 +192,16 @@ sanitized diagnostic message for the collapsed technical-details surface.
- **GIVEN** an OpenCode build without usable diagnostic stderr, **WHEN** its ACP
prompt hangs without further events, **THEN** the five-minute advisory notice
remains the recovery path.
- **GIVEN** a correlated OpenCode diagnostic containing a valid remediation URL,
**WHEN** the recovery surface settles, **THEN** Kanban and Office show a
localized external link while the sanitized message contains no URL or
workspace identifier.
- **GIVEN** a URL with the wrong host, scheme, path, query, fragment, or
malformed workspace identifier, **WHEN** it crosses the adapter boundary,
**THEN** Kandev drops the URL and shows the existing generic recovery copy.
- **GIVEN** OpenCode 1.18.5 returns only its current short ACP failure,
**WHEN** Kandev handles the error, **THEN** it shows the short failure without
pretending that the TUI-only URL was received.
- **GIVEN** the provider-limit recovery renders on a phone viewport, **WHEN**
the user reads details or selects a recovery action, **THEN** the same outcome
is available through touch targets of at least 44px with no horizontal page
@@ -177,7 +215,10 @@ sanitized diagnostic message for the collapsed technical-details surface.
- Reading, tailing, or exposing an agent vendor's private log files.
- Treating arbitrary stderr text as trusted terminal evidence.
- Persisting raw stderr or raw OpenCode log records.
- Following or exposing OpenCode account/balance URLs from provider errors.
- Following or exposing arbitrary provider URLs, account URLs, or balance URLs;
only the exact allowlisted OpenCode remediation route is in scope.
- Recovering a URL that was never present in ACP or managed structured stderr,
including by reading or tailing OpenCode's private log files.
- Automatically purchasing capacity, changing models, or scheduling a retry at
the reset time.
- Repairing OpenCode's ACP implementation upstream; OpenCode should still be