eleven / messenger Public

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>
Paul Campbell bb87ec0070af51f2eec2ee9d5272f8ec2c05389a parent b989174
3 files changed, +133 −5
  • internal/bot/connect.go +45 −3
  • internal/bot/connect_v2_test.go +80 −0
  • web/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);