feat: surface allowlisted OpenCode remediation links in recovery surfaces (#2396)
This commit is contained in:
@@ -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 ©
|
||||
}
|
||||
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 ©
|
||||
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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"noAgentsAvailable": "Ńō àĝēńţś àvàĩĺàƀĺē",
|
||||
"noRepositoriesAvailableInWorkspace": "Ńō ŕēƥōśĩţōŕĩēś àvàĩĺàƀĺē ĩń ţĥĩś ŵōŕķśƥàćē",
|
||||
"noRepository": "Ńō ŕēƥōśĩţōŕŷ",
|
||||
"openProviderPage": "Ōƥēń ƥŕōvĩďēŕ ƥàĝē",
|
||||
"optional": "(ōƥţĩōńàĺ)",
|
||||
"providerQuotaModel": "Ḿōďēĺ: {{model}}",
|
||||
"providerQuotaProviderFallback": "Ƥŕōvĩďēŕ",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user