diff --git a/bun.lock b/bun.lock index 6b63868..a634fb0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.2.0", + "@ellipsis-dev/sdk": "^0.2.3", "commander": "^12.1.0", "ink": "^5.0.1", "ink-spinner": "^5.0.0", @@ -28,7 +28,7 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.2.0", "", {}, "sha512-oxl3y4FZ7DX7uma3VXtBG0B+JjWcNfOo2l8z6YJE9UXKS+P1UHjmNm2nUsaQ8c0aaUxJksMFHJMjU2B2P5EANQ=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.2.3", "", {}, "sha512-VETp7aLWJe1q6vXHBZaCFh/RCYn59t7zQ8kWDvoT1zBFjfHLsQ4aL2usTnjBTTfblOTq2H+iiXrsHXfw0Gg56A=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index ee61b73..3b0f13a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.2.0", + "@ellipsis-dev/sdk": "^0.2.3", "commander": "^12.1.0", "ink": "^5.0.1", "ink-spinner": "^5.0.0", diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 252c212..486e7b0 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -13,12 +13,32 @@ import type { AgentSession } from './types' export const SELECTION_GLYPH = '▶' // Whether the composer can send to this session, and — when it can't — why. -// Only durable (keyed) sessions have an inbox loop to attend a message; -// single-shot and closed sessions open watch-only. +// +// The server decides: `prompting` (protocol §4.1) is the same projection +// POST /messages enforces, so the composer appears exactly where a send would +// succeed and we never open an input whose first Enter 409s. Its `detail` is +// the server's curated sentence, so a new refusal reason needs no CLI release +// to read well. A session that came from a surface (a Slack/GitHub/Linear +// mention) is steered THERE, not here, because that surface is where the +// agent's answers post. +// +// Pre-`prompting` servers (older deployments) omit the field: fall back to the +// local keyed/closed read those binaries already shipped with. export function connectability(session: AgentSession): { canSend: boolean reason?: string } { + const prompting = session.prompting + if (prompting) { + if (prompting.enabled) return { canSend: true } + const detail = prompting.detail?.trim() + return { + canSend: false, + reason: detail + ? `${detail} Opening watch-only.` + : 'this session does not accept messages — opening watch-only', + } + } if (!session.session_key) { return { canSend: false, diff --git a/src/lib/types.ts b/src/lib/types.ts index 7a3dae2..047f5bd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,7 @@ import type { AgentSessionSource, AgentSessionStatus, SessionMessageWire, + SessionPrompting, SessionRecordWire, SessionState, SessionSurface, @@ -26,6 +27,7 @@ export type { ListSessionRecordsResponse, ListSessionTurnsResponse, SendSessionMessageRequest, + SessionPrompting, SessionState, SessionSurface, } from '@ellipsis-dev/sdk' @@ -132,6 +134,13 @@ export interface AgentSession { run: string | null status: string | null } | null + // Whether a human may prompt this session, and the curated reason when they + // may not — the SAME projection POST /messages enforces, so `connectability` + // opens a composer only where a send would succeed. `detail` is server-authored + // copy: render it verbatim rather than switching on `blocked_reason`, so a new + // refusal reason reads correctly without a CLI release. Optional because + // servers predating the field omit it. + prompting?: SessionPrompting | null cost_tokens: number cost_sandbox_cpu: number cost_sandbox_memory: number diff --git a/test/sessions.test.ts b/test/sessions.test.ts index fe9e4d9..23977fe 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -51,6 +51,65 @@ describe('connectability', () => { expect(c.canSend).toBe(false) expect(c.reason).toMatch(/closed/) }) + + it('honors the server prompting projection over the local keyed read', () => { + // A Slack mention session is keyed and live — the old local rule called it + // sendable — but its answers post back to the Slack thread, so the server + // refuses direct messages and we open watch-only instead of offering a + // composer whose first Enter would 409. + const c = connectability( + session({ + session_key: 'slack:D1:1.1', + session_state: 'idle', + prompting: { + enabled: false, + blocked_reason: 'mention_surface', + detail: 'This conversation lives on Slack. Reply there to steer the agent.', + surface_name: 'Slack', + }, + }), + ) + expect(c.canSend).toBe(false) + // The server's own sentence is shown verbatim, so the reason names Slack. + expect(c.reason).toContain('This conversation lives on Slack.') + expect(c.reason).toMatch(/watch-only/) + }) + + it('sends when the server says prompting is enabled', () => { + const c = connectability( + session({ + session_key: 'api:x', + session_state: 'idle', + prompting: { + enabled: true, + blocked_reason: null, + detail: null, + surface_name: null, + }, + }), + ) + expect(c).toEqual({ canSend: true }) + }) + + it('falls back to the local read against servers with no prompting field', () => { + // Older deployments omit `prompting`; a keyed live session must still open + // with a composer rather than silently going watch-only. + expect(connectability(session({ session_key: 'api:x', session_state: 'idle' }))).toEqual({ + canSend: true, + }) + }) + + it('is watch-only with generic copy when the server sends no detail', () => { + const c = connectability( + session({ + session_key: 'api:x', + session_state: 'idle', + prompting: { enabled: false, blocked_reason: 'non_interactive', detail: null }, + }), + ) + expect(c.canSend).toBe(false) + expect(c.reason).toMatch(/does not accept messages/) + }) }) describe('rowStatusWord / rowGlyph', () => {