bot+web: a failed chip op gets explained in Eleven's voice, not just returned (#528)
A chip is the bot's own proposal, so its failure shouldn't be a raw provider
JSON dump in a notice ("status 404: {\"message\":\"Not Found\"...}") that the
member then has to ask about. Chip-originated ops now carry src:"chip"; when
one fails, the sidecar still returns the raw error on the connect.result (the
instant notice stays, honest and immediate) and then the brain follows up
in-thread with a short plain-language explanation — cause and concrete fix,
drawing on the knowledge base's fresh error decoder (404-means-not-installed
and friends). App-surface fetches (the GitHub app's Refresh) stay untagged
and keep their inline errors — no bot non-sequiturs in the DM.
Deterministic sidecars (no ANTHROPIC key) skip the explanation and keep
today's behaviour. Test drives the 404 chip path end to end: error result +
spoken explanation for src:"chip", result-only (brain never asked) without.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+133
−5
internal/bot/connect.go+45 −3internal/bot/connect_v2_test.go+80 −0web/static/shell.js+8 −2
diff --git a/internal/bot/connect.go b/internal/bot/connect.go| index 237320e..a83c9b2 100644 |
| --- a/internal/bot/connect.go |
| +++ b/internal/bot/connect.go |
| @@ -4,6 +4,7 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| + "strings" |
| "sync" |
| "time" |
| @@ -71,6 +72,7 @@ type connectPayload struct { |
| State string `json:"state"` // OAuth |
| UID string `json:"uid"` // sender's user id (to seal the token back) |
| Name string `json:"name"` |
| + Src string `json:"src"` // "chip" = a tapped bot proposal (its failure gets a spoken explanation) |
| } |
| // grant is what a member sealed to the bot for one capability op. |
| @@ -107,6 +109,17 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload) |
| r.logf("connect.result: %v", err) |
| } |
| } |
| + // A chip is this bot's own proposal, so its failure is answered in the |
| + // bot's voice: the raw error still goes back on the result (the instant |
| + // notice), and the brain follows with a plain-language explanation in the |
| + // same thread. Chip ops only — an app surface (the GitHub app's Refresh) |
| + // renders its own inline errors, and a bot message there would be noise. |
| + fail := func(msg string) { |
| + reply(map[string]any{"error": msg}) |
| + if p.Src == "chip" { |
| + go r.explainConnectError(threadID, key, p, msg) |
| + } |
| + } |
| if g.Exp != 0 && g.Exp < time.Now().UnixMilli() { |
| reply(map[string]any{"error": "That authorization expired — try again."}) |
| return |
| @@ -139,7 +152,7 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload) |
| if g.Refresh != "" && g.TokenExp != 0 && time.Now().UnixMilli() > g.TokenExp-60_000 { |
| pair, err := r.refreshGrant(&g, p.UID) |
| if err != nil { |
| - reply(map[string]any{"error": err.Error()}) |
| + fail(err.Error()) |
| return |
| } |
| token, renewed = pair.Access, pair |
| @@ -156,7 +169,7 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload) |
| } |
| } |
| if err != nil { |
| - reply(map[string]any{"error": err.Error()}) |
| + fail(err.Error()) |
| return |
| } |
| // An op result rides inside ONE encrypted message (64 KB blob cap, |
| @@ -164,7 +177,7 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload) |
| // would hand the app broken JSON — refuse honestly instead; ops |
| // bound their responses with the manifest's `pick` projection. |
| if len(data) > maxOpResultBytes { |
| - reply(map[string]any{"error": "That result is too large to bring into the chat — narrow the request."}) |
| + fail("That result is too large to bring into the chat — narrow the request.") |
| return |
| } |
| out := map[string]any{"data": data} |
| @@ -240,6 +253,35 @@ func (r *Runtime) handleOAuthExchange(threadID string, key []byte, p *connectPay |
| } |
| } |
| +// explainConnectError has the brain own a failed chip op conversationally: |
| +// the chip was this bot's proposal, so its failure gets a short plain-language |
| +// reply in-thread — what went wrong and the concrete fix (the knowledge base |
| +// carries the decoder for the common cases, e.g. GitHub's 404-means-not- |
| +// installed). The raw error already went back on the connect.result; a bot |
| +// with no brain just leaves it at that. |
| +func (r *Runtime) explainConnectError(threadID string, key []byte, p *connectPayload, errMsg string) { |
| + if r.ai == nil { |
| + return |
| + } |
| + resp, err := r.ai.create(anthRequest{ |
| + Model: r.model, |
| + MaxTokens: 1024, |
| + System: r.systemBlocks(), |
| + Messages: []anthMessage{{Role: "user", Content: "[automatic system note — not written by the member] " + |
| + "The member just tapped your \"" + p.Op + "\" chip for " + p.Service + " and the operation FAILED. " + |
| + "The raw error (already shown to them as a notice): " + errMsg + " — " + |
| + "In one short, friendly message: acknowledge it didn't work, explain the likely cause in plain " + |
| + "language, and give the concrete fix. No tool calls, no new chips, no apologising twice."}}, |
| + }) |
| + if err != nil { |
| + r.logf("connect explain: %v", err) |
| + return |
| + } |
| + if text := strings.TrimSpace(resp.text()); text != "" { |
| + r.postReply(threadID, key, Reply{Text: text}) |
| + } |
| +} |
| + |
| // refreshGrant rotates the grant's expiring pair via the broker. It refuses |
| // to rotate when the fresh pair couldn't be handed back (no resolvable member |
| // key) — rotation consumes the stored refresh token, so losing the result |
diff --git a/internal/bot/connect_v2_test.go b/internal/bot/connect_v2_test.go| index c8a4c97..b32b89f 100644 |
| --- a/internal/bot/connect_v2_test.go |
| +++ b/internal/bot/connect_v2_test.go |
| @@ -458,6 +458,86 @@ func TestConnectRefusesRefreshWithoutMemberKey(t *testing.T) { |
| } |
| } |
| +// A FAILED chip op (src:"chip") is answered twice: the raw error on the |
| +// connect.result (the instant notice), then the brain's plain-language |
| +// explanation as a normal in-thread message. An untagged op (an app surface's |
| +// fetch) gets the error result only — no bot chatter. |
| +func TestConnectChipFailureGetsSpokenExplanation(t *testing.T) { |
| + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + io.WriteString(w, `{"message":"Not Found"}`) |
| + })) |
| + t.Cleanup(gh.Close) |
| + t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL) |
| + var prompts []string |
| + claude := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var req struct { |
| + Messages []struct { |
| + Content string `json:"content"` |
| + } `json:"messages"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + if len(req.Messages) > 0 { |
| + prompts = append(prompts, req.Messages[0].Content) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + io.WriteString(w, `{"content":[{"type":"text","text":"That didn't work — the app isn't installed on that repo's org. Install it at github.com/apps/eleven-messenger and try again."}],"stop_reason":"end_turn"}`) |
| + })) |
| + t.Cleanup(claude.Close) |
| + t.Setenv("ANTHROPIC_BASE_URL", claude.URL) |
| + |
| + h := newConnectHarness(t) |
| + h.rt.ai = newAnthropicClient("test-key") |
| + h.rt.model = defaultModel |
| + |
| + grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"}) |
| + h.rt.handleConnect("t1", h.key, &connectPayload{ |
| + Type: "connect.op", Service: "github", Op: "issues.list", |
| + Args: map[string]string{"owner": "some", "repo": "where"}, Grant: grant, RID: "r-chip", Src: "chip", |
| + }) |
| + // Two messages arrive: the error result and the spoken explanation (order |
| + // can race — the explanation runs on its own goroutine). |
| + var sawResult, sawSpoken bool |
| + for i := 0; i < 2; i++ { |
| + p := h.waitReply(t) |
| + switch p["type"] { |
| + case "connect.result": |
| + if e, _ := p["error"].(string); !strings.Contains(e, "404") { |
| + t.Fatalf("result error: %v", p["error"]) |
| + } |
| + sawResult = true |
| + default: |
| + if txt, _ := p["text"].(string); strings.Contains(txt, "isn't installed") { |
| + sawSpoken = true |
| + } |
| + } |
| + } |
| + if !sawResult || !sawSpoken { |
| + t.Fatalf("want error result + spoken explanation, got result=%v spoken=%v", sawResult, sawSpoken) |
| + } |
| + if len(prompts) != 1 || !strings.Contains(prompts[0], "issues.list") || !strings.Contains(prompts[0], "404") { |
| + t.Fatalf("explain prompt: %v", prompts) |
| + } |
| + |
| + // The same failure UNTAGGED: error result only, and the brain is never asked. |
| + h.rt.handleConnect("t1", h.key, &connectPayload{ |
| + Type: "connect.op", Service: "github", Op: "issues.list", |
| + Args: map[string]string{"owner": "some", "repo": "where"}, Grant: grant, RID: "r-app", |
| + }) |
| + p := h.waitReply(t) |
| + if p["type"] != "connect.result" || p["error"] == nil { |
| + t.Fatalf("untagged failure: %v", p) |
| + } |
| + select { |
| + case extra := <-h.replies: |
| + t.Fatalf("untagged failure must not speak: %v", extra) |
| + case <-time.After(300 * time.Millisecond): |
| + } |
| + if len(prompts) != 1 { |
| + t.Fatalf("brain was asked %d times, want 1 (chip only)", len(prompts)) |
| + } |
| +} |
| + |
| func TestConnectLegacyDNSimpleListStillWorks(t *testing.T) { |
| // The manifest's dnsimple op is "domains.list" — bare "list" is the |
| // legacy DNSimple-app protocol and must keep flowing through the old |
diff --git a/web/static/shell.js b/web/static/shell.js| index 6ac8168..4e45888 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -238,7 +238,9 @@ const CHIP_RUNNERS = { |
| // notice in the thread the chip was tapped in. |
| service_op: (a) => { |
| const threadId = activeId; |
| - requestOp(a.service, a.op, a.args, a.access).then((res) => |
| + requestOp(a.service, a.op, a.args, a.access, 120000, "chip").then((res) => |
| + // On failure the raw error lands here instantly; the bot follows up |
| + // in-thread with a plain-language explanation (the src:"chip" tag). |
| appendNotice(threadId, { text: res.ok ? summarizeOpResult(a, res) : `Couldn't run that: ${res.error}` }) |
| ); |
| return a.access === "write" ? "Confirmed ✓" : "Requested ✓"; |
| @@ -2790,7 +2792,7 @@ async function openOpChannel(service) { |
| // with the manifest's named args, and await the structured `connect.result` |
| // matched by rid. Used by the service_op consent chip and exposed to apps as |
| // ctx.host.connected.requestOp. |
| -async function requestOp(service, op, args, access, ttlMs = 120000) { |
| +async function requestOp(service, op, args, access, ttlMs = 120000, src = "") { |
| const ch = await openOpChannel(service); |
| if (ch.error) return { ok: false, error: ch.error }; |
| const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12))); |
| @@ -2805,6 +2807,10 @@ async function requestOp(service, op, args, access, ttlMs = 120000) { |
| token_expires_at: ch.opened.expires_at, |
| }); |
| const payload = { type: "connect.op", service, op, args: args || {}, grant, rid, name: session.name, ts: Date.now() }; |
| + // Chip-originated ops are the bot's own proposals: tagging them lets the |
| + // sidecar answer a FAILURE in the bot's voice (a plain-language explanation |
| + // in-thread) — app-surface fetches stay untagged and render their own errors. |
| + if (src) payload.src = src; |
| if (myUserId) payload.uid = myUserId; |
| ws.send(JSON.stringify({ type: "msg", thread_id: ch.target.thread.id, blob: await encrypt(ch.key, payload) })); |
| const res = await awaitConnectResult(ch.target.thread.id, ch.key, rid); |