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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 22 additions & 2 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
AgentSessionSource,
AgentSessionStatus,
SessionMessageWire,
SessionPrompting,
SessionRecordWire,
SessionState,
SessionSurface,
Expand All @@ -26,6 +27,7 @@ export type {
ListSessionRecordsResponse,
ListSessionTurnsResponse,
SendSessionMessageRequest,
SessionPrompting,
SessionState,
SessionSurface,
} from '@ellipsis-dev/sdk'
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions test/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down