Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/peek-connector-inslack-consent-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@peekdev/mcp": patch
---

Make the per-action consent prompt human-readable. `buildElicitMessage` now
produces a masked, verb-specific sentence (e.g. *peek wants to Type "m•••m" into
`#email` on your live browser. Approve?*) instead of a generic
`run "<type>"` string. Literal values (`type` text, `request_user_input` prompt)
are masked to the first and last character so no secret is rendered in the
connecting client's chat history. No MCP-contract change — the tool input schema
is unchanged; only the elicitation message text differs.
88 changes: 87 additions & 1 deletion packages/connector-core/src/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { AgentOutcome, Brain, Session } from './brain.js';
import type { PeekMcp } from './mcp.js';
import { ConnectorRuntime } from './runtime.js';
import { ConnectorRuntime, classifyError } from './runtime.js';
import type { SecretStore } from './secret-store.js';
import { SessionStore } from './store.js';
import type { ConsentResponse, InboundMessage, SurfaceAdapter } from './surface.js';
Expand Down Expand Up @@ -430,6 +430,92 @@ describe('ConnectorRuntime turn serialization (concurrency clobber fix)', () =>
});
});

describe('classifyError', () => {
// Real thrown-message grounding (verified against source before writing fixtures):
// - mcp connect: withTimeout label='mcp connect' → "mcp connect timed out after 10000ms"
// - mcp callTool: withTimeout label=`mcp callTool(${name})` → "mcp callTool(list_recent_sessions) timed out after 30000ms"
// - 401 auth: AuthenticationError.makeMessage → "401 {message from API}" (contains '401')
// - connection error: APIConnectionError → "Connection error." (contains 'connection error')
// - max-turns: SdkBrain → "SdkBrain exceeded 16 tool-use turns" (contains 'tool-use turns')
// NOTE: brief fixtures used 'Exceeded maxTurns (16) without a final answer' — WRONG.
// Real message does NOT contain 'maxturns', 'max turns', or 'max-turns'. Fixed here.
const cases: Array<[unknown, string]> = [
[new Error('mcp connect timed out after 10000ms'), 'mcp-connection-lost'],
[new Error('mcp callTool(list_recent_sessions) timed out after 30000ms'), 'tool-error'],
[
new Error('401 {"message":"invalid x-api-key","type":"authentication_error"}'),
'llm-key-rejected',
],
[new Error('Connection error.'), 'llm-endpoint-error'],
[new Error('No recording found for this browser session'), 'not-recording'],
[new Error('elicitInput deny reason: timeout'), 'consent-timeout'],
[new Error('SdkBrain exceeded 16 tool-use turns'), 'max-turns'],
['a bare string with no signal', 'unknown'],
[{ weird: true }, 'unknown'],
];
for (const [err, kind] of cases) {
it(`classifies ${kind}`, () => {
const out = classifyError(err);
expect(out.kind).toBe(kind);
expect(out.headline.length).toBeGreaterThan(0);
expect(out.hint.length).toBeGreaterThan(0);
});
}
});

describe('runLoop error legibility', () => {
it('calls postError with a classified kind when a turn throws', async () => {
const brain: Brain = {
newSession: (): Session => ({ history: [] }),
appendUserText: () => {},
appendToolResult: () => {},
runTurn: async () => {
throw new Error('401 {"message":"invalid x-api-key","type":"authentication_error"}');
},
};
class ErrAdapter extends FakeAdapter {
errors: Array<[string, { kind: string; headline: string; hint: string }]> = [];
async postError(c: string, e: { kind: string; headline: string; hint: string }) {
this.errors.push([c, e]);
}
}
const adapter = new ErrAdapter();
const store = new SessionStore(brain.newSession);
const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp;
const runtime = new ConnectorRuntime({ adapter, brain, mcp, store });
await runtime.start();
adapter.msgHandler?.({ conversationId: 't1', userId: 'u', text: 'hi' });
await vi.waitFor(() => expect(adapter.errors).toHaveLength(1));
expect(adapter.errors[0]?.[1].kind).toBe('llm-key-rejected');
expect(adapter.texts).toHaveLength(0); // used postError, not plain text
});

it('falls back to postText when the adapter has no postError', async () => {
const brain: Brain = {
newSession: (): Session => ({ history: [] }),
appendUserText: () => {},
appendToolResult: () => {},
runTurn: async () => {
throw new Error('boom');
},
};
const adapter = new FakeAdapter(); // no postError
const store = new SessionStore(brain.newSession);
const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp;
const runtime = new ConnectorRuntime({ adapter, brain, mcp, store });
await runtime.start();
adapter.msgHandler?.({ conversationId: 't1', userId: 'u', text: 'hi' });
await vi.waitFor(() => expect(adapter.texts).toHaveLength(1));
expect(adapter.texts[0]?.[0]).toBe('t1');
// The composed text must contain the classified headline AND hint for the unknown kind.
// classifyError('boom') → kind:'unknown', headline:'Something went wrong reaching peek',
// hint:'Please try again. If it keeps happening, check the connector logs.'
const postedText = adapter.texts[0]?.[1] ?? '';
expect(postedText).toContain('Something went wrong reaching peek');
expect(postedText).toContain('Please try again');
});
});

describe('ConnectorRuntime handler rejection', () => {
it('does not produce an unhandled rejection when handleMessage rejects', async () => {
// A brain whose runTurn always rejects
Expand Down
92 changes: 90 additions & 2 deletions packages/connector-core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { SecretStore } from './secret-store.js';
import type { SessionStore } from './store.js';
import type { ConsentResponse, InboundMessage, SurfaceAdapter } from './surface.js';

const ERROR_TEXT = '⚠️ Something went wrong reaching peek. Please try again.';
const DENY_RESULT =
'The user denied this action. Do not retry it; explain or suggest an alternative.';

Expand All @@ -14,6 +13,90 @@ function mintCorrelationId(): string {
return `pc-${Date.now()}-${correlationCounter}`;
}

/** Defensively classify a caught turn error into a small, legible set. The default
* {kind:'unknown'} branch ensures a provider swap (whose error strings differ)
* can never break error handling — classification is provider-coupled, so it is
* best-effort and always falls through to a safe generic. Hints are SUGGESTIVE,
* not authoritative.
*
* Substring grounding (verified against mcp.ts + sdk-brain.ts + @anthropic-ai/sdk):
* - 'mcp connect' → withTimeout label 'mcp connect' → "mcp connect timed out after Nms"
* - 'mcp calltool' → withTimeout label `mcp callTool(${name})` → "mcp callTool(X) timed out after Nms"
* (checked BEFORE generic timeout branches so a callTool-timeout → tool-error, not consent-timeout)
* - '401' → AuthenticationError.makeMessage → "401 {error message from API}"
* - 'connection error' → APIConnectionError → "Connection error." (case-insensitive)
* - 'tool-use turns' → SdkBrain → "SdkBrain exceeded N tool-use turns"
* (brief used 'maxturns'/'max turns' which do NOT appear in the real message — fixed)
* - 'timeout' + ('elicit'|'consent') → peek-mcp elicitation deny/timeout text */
export function classifyError(err: unknown): { kind: string; headline: string; hint: string } {
const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const m = msg.toLowerCase();
if (m.includes('mcp connect')) {
return {
kind: 'mcp-connection-lost',
headline: 'Lost the connection to peek',
hint: 'The peek daemon may have stopped. Check that it is running, then try again.',
};
}
if (m.includes('mcp calltool')) {
return {
kind: 'tool-error',
headline: 'A peek tool call failed',
hint: 'The action or query did not complete. Try rephrasing or ask again.',
};
}
if (
m.includes('401') ||
m.includes('unauthorized') ||
m.includes('x-api-key') ||
m.includes('invalid api key')
) {
return {
kind: 'llm-key-rejected',
headline: 'The AI provider rejected the API key',
hint: 'Check the model API key configured for the connector.',
};
}
if (
m.includes('econnrefused') ||
m.includes('connection error') ||
m.includes('fetch failed') ||
m.includes('enotfound')
) {
return {
kind: 'llm-endpoint-error',
headline: "Couldn't reach the AI provider",
hint: 'The model endpoint may be down or the base URL misconfigured. Try again shortly.',
};
}
if (m.includes('no recording') || m.includes('not recording') || m.includes('no session')) {
return {
kind: 'not-recording',
headline: 'No recorded session to work with',
hint: 'Open the peek extension and record a browser session first.',
};
}
if (m.includes('timeout') && (m.includes('elicit') || m.includes('consent'))) {
return {
kind: 'consent-timeout',
headline: 'The approval request timed out',
hint: 'No Approve/Deny was received in time. Send your request again.',
};
}
if (m.includes('tool-use turns')) {
return {
kind: 'max-turns',
headline: 'The turn ran out of steps',
hint: 'peek reached its per-turn step limit. Narrow the request and try again.',
};
}
return {
kind: 'unknown',
headline: 'Something went wrong reaching peek',
hint: 'Please try again. If it keeps happening, check the connector logs.',
};
}

export interface RuntimeDeps {
adapter: SurfaceAdapter;
brain: Brain;
Expand Down Expand Up @@ -161,7 +244,12 @@ export class ConnectorRuntime {
}
} catch (err) {
console.error('connector loop error:', err);
await adapter.postText(conversationId, ERROR_TEXT);
const classified = classifyError(err);
if (adapter.postError) {
await adapter.postError(conversationId, classified);
} else {
await adapter.postText(conversationId, `${classified.headline}. ${classified.hint}`);
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/connector-core/src/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ export interface SurfaceAdapter {
postText(conversationId: string, text: string): Promise<void>;
postConsentRequest(conversationId: string, req: ConsentRequest): Promise<void>;
postConfirmation(conversationId: string, text: string): Promise<void>;
/** Optional: post a classified, legible error. Runtime null-checks it, so an
* adapter that doesn't implement it degrades to postText. */
postError?(
conversationId: string,
err: { kind: string; headline: string; hint: string },
): Promise<void>;
}
39 changes: 39 additions & 0 deletions packages/connector-slack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# @peekdev/connector-slack

Slack surface adapter for the peek connector platform. Connects a peek agent
to Slack via Bolt's Socket Mode, routing Assistant thread messages and `/peek`
slash commands to the connector core.

## Slack app setup

### Required scopes

| Scope | Why |
|---|---|
| `assistant:write` | Required to register the app as an AI assistant in Slack |
| `chat:write` | Required to post messages and set the "thinking…" status |

### Slack app scope — `chat:write`

The assistant "thinking…" status calls `assistant.threads.setStatus`. Slack is
migrating this capability from the `assistant:write` scope to `chat:write`. Add
**`chat:write`** to the bot token scopes in your Slack app manifest. Without it
the status is silently skipped (the turn still works); every other message uses
`chat.postMessage`, which also requires `chat:write`.

## Usage

```ts
import { SlackAdapter } from '@peekdev/connector-slack';

const adapter = new SlackAdapter({
slackBotToken: process.env.SLACK_BOT_TOKEN,
slackAppToken: process.env.SLACK_APP_TOKEN,
});

adapter.onMessage(async (msg) => {
// Handle inbound messages from Slack
});

await adapter.start();
```
Loading
Loading