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
27 changes: 16 additions & 11 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,22 +937,27 @@ export class SessionManager extends EventEmitter {
action: ConditionCustomAction,
): Promise<ResolveConditionResult> {
const entry = this.sessions.get(sessionId)
if (!entry || entry.kind !== 'claude') {
// ANY agent kind may carry a resolver now (#394 phase 3) — the
// old `entry.kind !== 'claude'` gate meant a provider that
// implemented AgentSession.resolveCondition still got a dead-end
// 'no-session' from the manager (#394 §5). `=== 'terminal'`
// rather than !isAgentProviderKind because TypeScript only
// discriminates the RegistryEntry union on literal comparisons.
// A provider without a resolver (Codex today — its approvals
// answer via raw keystrokes) hits the capability check below and
// returns 'no-resolver', which is truthful.
if (!entry || entry.kind === 'terminal') {
return { ok: false, reason: 'no-session' }
}
if (typeof entry.session.resolveCondition !== 'function') {
return { ok: false, reason: 'no-resolver' }
}
// The AgentSession contract types `reason` as a plain string (see
// @shared/types/session.ts: a third provider needs freedom to
// define its own failure vocabulary). ResolveConditionResult
// narrows to Claude's known reasons for consumers; the runtime
// value satisfies it, but the compiler can't prove it here without
// the widened return type from the contract. Cast is safe because
// the manager only routes claude-kind sessions to this method
// (the entry.kind !== 'claude' guard above), and Claude's
// resolveCondition still returns the narrow shape. Phase 3
// (conditions v2) generalizes this to a per-provider reason set.
// The AgentSession contract types `reason` as a plain string (a
// provider defines its own failure vocabulary); the app-side
// ResolveConditionResult narrows to the known reason set for
// renderer/IPC consumers. The cast asserts providers keep their
// reasons within that set — Claude's DriveResult does; a new
// provider's resolver must too (documented on the contract).
return entry.session.resolveCondition(action) as Promise<ResolveConditionResult>
}

Expand Down
59 changes: 59 additions & 0 deletions src/providers/claude/renderer/conditions/policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Claude condition policy (#394 phase 3) — the per-provider data the
// generic workspace condition selectors consume. Previously these
// lived as hardcoded kind-string lists + `provider === 'claude'`
// branches inside workspace/conditions/selectors.ts (the exact seam
// docs/design/conditions-system.md and conditions-core/view.ts:27-32
// deferred: "the migration is a deletion, not a re-derivation").
//
// WHY policy data instead of deriving from the registered views'
// `attention()` fns: the sets and the views deliberately DISAGREE —
// ask-user-question is attention-worthy but has NO outlet view (it
// renders as a feed row), while compaction HAS a view with an
// attention fn but is excluded from the attention set (progress, not
// actionable). Derivation from views would silently change both.

import type { ProviderConditionPolicy } from '@providers/registry.renderer.capabilities'

export const CLAUDE_CONDITION_POLICY: ProviderConditionPolicy = {
// Live, user-actionable prompts. EXCLUDES claude.compaction
// (progress, not actionable) and claude.slash-picker (a composer
// affordance, not an attention surface).
attentionKinds: new Set([
'claude.trust-dialog',
'claude.resume-prompt',
'claude.permission-prompt',
'claude.ask-user-question',
]),
// Blocking-input kinds for keystroke routing (presence-gated, not
// visible-gated — see hasActionCondition's docstring). Same
// membership as attentionKinds today; kept separate because the
// gates differ and a future kind may belong to one but not the
// other.
actionKinds: new Set([
'claude.trust-dialog',
'claude.resume-prompt',
'claude.permission-prompt',
'claude.ask-user-question',
]),
// Priority-ordered dispatch badge rules — first match wins.
// 'QUESTION' is deliberately outside conditions-core's
// AttentionLevel union; dispatch badges are free-form strings.
attentionLabels: [
{ kind: 'claude.permission-prompt', label: 'ACTION' },
// A live AskUserQuestion picker means the agent is BLOCKED
// waiting for the user to choose — it needs attention just like
// a permission/trust prompt.
{ kind: 'claude.ask-user-question', label: 'QUESTION' },
{ kind: 'claude.trust-dialog', label: 'TRUST' },
{ kind: 'claude.resume-prompt', label: 'RESUME' },
{
kind: 'claude.compaction',
// Compaction is a badge only when it FAILED — running/done
// phases are progress, not attention.
label: state =>
(state as { phase?: string } | null)?.phase === 'error' ? 'ERROR' : null,
},
],
// The composer's slash-command dropdown reads this kind's state.
composerPickerKind: 'claude.slash-picker',
}
15 changes: 15 additions & 0 deletions src/providers/codex/renderer/conditions/policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Codex condition policy (#394 phase 3). See the Claude counterpart
// for why this is explicit data rather than derived from views.

import type { ProviderConditionPolicy } from '@providers/registry.renderer.capabilities'

export const CODEX_CONDITION_POLICY: ProviderConditionPolicy = {
attentionKinds: new Set(['codex.trust-dialog', 'codex.approval']),
actionKinds: new Set(['codex.trust-dialog', 'codex.approval']),
attentionLabels: [
{ kind: 'codex.approval', label: 'ACTION' },
{ kind: 'codex.trust-dialog', label: 'TRUST' },
],
// Codex has no slash-picker condition — the composer picker stays
// hidden for codex panes (composerPickerKind omitted).
}
32 changes: 32 additions & 0 deletions src/providers/registry.renderer.capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
import type { TranscriptEntryMapper } from '@shared/types/providerConfig'
import { CLAUDE_IDENTITY } from '@providers/claude/renderer/identity'
import { claudeComposerSubmit } from '@providers/claude/renderer/composerSubmit'
import { CLAUDE_CONDITION_POLICY } from '@providers/claude/renderer/conditions/policy'
import { CODEX_CONDITION_POLICY } from '@providers/codex/renderer/conditions/policy'
import { codexComposerSubmit } from '@providers/codex/renderer/composerSubmit'
import { CODEX_IDENTITY } from '@providers/codex/renderer/identity'
import {
Expand Down Expand Up @@ -86,6 +88,34 @@ export type RendererProviderCapabilities = {
* workspace store.
*/
usesOptimisticUserEcho: boolean
/**
* Condition policy (#394 phase 3): the per-provider data the
* generic workspace condition selectors consume (attention set,
* blocking-input set, priority-ordered dispatch badge rules,
* composer picker kind). Replaced selectors.ts's hardcoded kind
* lists and provider branches.
*/
conditionPolicy: ProviderConditionPolicy
}

/** See providers/<kind>/renderer/conditions/policy.ts for the concrete
* policies and the WHY on each field's gating semantics. */
export type ProviderConditionPolicy = {
/** Kinds that mark a backgrounded pane unread (visible-gated). */
attentionKinds: ReadonlySet<string>
/** Kinds that route keystrokes to the PTY instead of the composer
* (presence-gated). */
actionKinds: ReadonlySet<string>
/** Priority-ordered dispatch-badge rules; first match wins. Labels
* are free-form strings (deliberately wider than conditions-core's
* AttentionLevel — 'QUESTION' exists only here). */
attentionLabels: ReadonlyArray<{
kind: string
label: string | ((state: unknown) => string | null)
}>
/** Condition kind whose state feeds the composer's slash-command
* dropdown; omitted when the provider has no picker condition. */
composerPickerKind?: string
}

/** IO bag for composerSubmit. Draft images are structural (not the
Expand Down Expand Up @@ -113,6 +143,7 @@ const claudeCapabilities: RendererProviderCapabilities = {
composerSubmit: claudeComposerSubmit,
supportsImageAttachments: true,
usesOptimisticUserEcho: false,
conditionPolicy: CLAUDE_CONDITION_POLICY,
}

const codexCapabilities: RendererProviderCapabilities = {
Expand All @@ -128,6 +159,7 @@ const codexCapabilities: RendererProviderCapabilities = {
composerSubmit: codexComposerSubmit,
supportsImageAttachments: false,
usesOptimisticUserEcho: true,
conditionPolicy: CODEX_CONDITION_POLICY,
}

const rendererProviderCapabilities: Record<AgentProviderKind, RendererProviderCapabilities> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,16 @@ export function ProviderConditionOutlet({ conditions, onSend, onResolveCustom }:
const { conditionViews: registry } = getRendererProviderCapabilities(conditions.provider)
const dispatch = makeDispatchFromOnSend(onSend, onResolveCustom)

// The app-side open snapshot types its map Partial (an artifact of
// the per-provider mapped types it must absorb); conditions-core's
// ConditionSnapshot wants a dense Record. Values are never actually
// undefined at runtime — the evaluators emit dense maps and records
// are only ever inserted whole. This cast is the ONE sanctioned
// erasure at the outlet boundary, same role as `eraseRegistry` for
// views (#394 phase 3).
const snapshot = conditions as import('@shared/conditions-core/contract').ConditionSnapshot

return (
<ConditionOutlet snapshot={conditions} registry={registry} dispatch={dispatch} />
<ConditionOutlet snapshot={snapshot} registry={registry} dispatch={dispatch} />
)
}
22 changes: 13 additions & 9 deletions src/renderer/src/features/debug/devModules/Conditions/module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import type {
SemanticLiveBlock,
SemanticLiveTurn,
} from '@renderer/workspace/workspaceState'
import type { ProviderConditionSnapshot } from '@shared/types/providerConditions'
import {
conditionStateByKind,
type ClaudeAskUserQuestionState,
type ProviderConditionSnapshot,
} from '@shared/types/providerConditions'

export const conditionsDebugModule: DevDebugModule = {
id: 'conditions',
Expand Down Expand Up @@ -136,10 +140,10 @@ function ConditionsDebug({ sessionId, runtime, kind }: DevDebugModuleProps) {
const attention = dispatchAttentionLabelFromConditions(snapshot)
const actionCondition = hasActionCondition(snapshot)
const slashFromConditions = slashPickerFromConditions(snapshot)
const auqFromConditions =
snapshot?.provider === 'claude'
? snapshot.conditions['claude.ask-user-question']?.state ?? null
: null
const auqFromConditions = conditionStateByKind<ClaudeAskUserQuestionState>(
snapshot ?? null,
'claude.ask-user-question',
)
const auqBlocks = useMemo(
() => collectAskUserQuestionBlocks(runtime.semantic.currentTurn, runtime.semantic.history),
[runtime.semantic.currentTurn, runtime.semantic.history],
Expand Down Expand Up @@ -296,10 +300,10 @@ function buildConditionsCopyText(
const attention = dispatchAttentionLabelFromConditions(snapshot)
const actionCondition = hasActionCondition(snapshot)
const slashFromConditions = slashPickerFromConditions(snapshot)
const auqFromConditions =
snapshot?.provider === 'claude'
? snapshot.conditions['claude.ask-user-question']?.state ?? null
: null
const auqFromConditions = conditionStateByKind<ClaudeAskUserQuestionState>(
snapshot ?? null,
'claude.ask-user-question',
)
const auqBlocks = collectAskUserQuestionBlocks(runtime.semantic.currentTurn, runtime.semantic.history)
const full = mode === 'full'
const payload = {
Expand Down
81 changes: 38 additions & 43 deletions src/renderer/src/workspace/conditions/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { ProviderConditionSnapshot } from '@shared/types/providerConditions'
import {
conditionStateByKind,
type ClaudeSlashPickerState,
type ProviderConditionSnapshot,
} from '@shared/types/providerConditions'
import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities'
import type { SessionRuntime } from '@renderer/workspace/workspaceState'

// WHY this predicate exists (conditions audit Finding 2 + Additional A):
Expand Down Expand Up @@ -71,17 +76,16 @@ export function clearConditionRuntimeState(): Pick<
}
}

// The condition kinds that represent a live, user-actionable prompt the user
// must attend to. EXCLUDES claude.compaction (progress, not actionable) and
// claude.slash-picker (a composer affordance, not an attention surface).
const ATTENTION_CONDITION_KINDS: ReadonlySet<string> = new Set([
'claude.trust-dialog',
'claude.resume-prompt',
'claude.permission-prompt',
'claude.ask-user-question',
'codex.trust-dialog',
'codex.approval',
])
// WHY these selectors are now generic loops over the provider's
// conditionPolicy (#394 phase 3): the kind lists and `provider ===
// 'claude'` branches that lived here were the deferred seam
// docs/design/conditions-system.md called out — hand-maintained
// per-provider knowledge in shell code. The policy data (attention
// set, action set, ordered label rules, picker kind) now lives with
// each provider (providers/<kind>/renderer/conditions/policy.ts) and
// a registered third provider participates in unread badges,
// keystroke routing, dispatch labels, and the composer picker with
// zero edits here.

// WHY this exists (conditions audit Finding 5 + Additional B): the unread/
// attention transition in useIpcSubscriptions used to read the legacy `pending*`
Expand All @@ -96,59 +100,50 @@ export function conditionRequiresAttention(
snapshot: ProviderConditionSnapshot | null,
): boolean {
if (!snapshot) return false
const policy = getRendererProviderCapabilities(snapshot.provider).conditionPolicy
for (const [kind, condition] of Object.entries(snapshot.conditions)) {
if (!condition || !ATTENTION_CONDITION_KINDS.has(kind)) continue
if (!condition || !policy.attentionKinds.has(kind)) continue
const state = condition.state as { visible?: boolean }
if (typeof state.visible === 'boolean' ? state.visible : true) return true
}
return false
}

// Presence-gated (NOT visible-gated) on purpose — see the policy
// docstrings. Used by keystroke routing: while a blocking condition
// record exists, arrow keys go to the provider TUI, not the composer.
export function hasActionCondition(
conditions: ProviderConditionSnapshot | null,
): boolean {
if (!conditions) return false
if (conditions.provider === 'claude') {
return Boolean(
conditions.conditions['claude.trust-dialog'] ||
conditions.conditions['claude.resume-prompt'] ||
conditions.conditions['claude.permission-prompt'] ||
conditions.conditions['claude.ask-user-question'],
)
const policy = getRendererProviderCapabilities(conditions.provider).conditionPolicy
for (const kind of policy.actionKinds) {
if (conditions.conditions[kind]) return true
}
return Boolean(
conditions.conditions['codex.trust-dialog'] ||
conditions.conditions['codex.approval'],
)
return false
}

export function dispatchAttentionLabelFromConditions(
conditions: ProviderConditionSnapshot | null,
): string | null {
if (!conditions) return null
if (conditions.provider === 'claude') {
if (conditions.conditions['claude.permission-prompt']) return 'ACTION'
// A live AskUserQuestion picker means the agent is BLOCKED waiting for the
// user to choose — it needs attention just like a permission/trust prompt,
// so it surfaces a dispatch badge ('QUESTION') the same way. This is the
// The inline row still renders from transcript state (`!resultAt`), but this
// condition is now a real blocking input surface too: keybinding routing uses
// `hasActionCondition` so arrow keys keep reaching Claude's picker while the
// user decides from the terminal instead of the feed row.
if (conditions.conditions['claude.ask-user-question']) return 'QUESTION'
if (conditions.conditions['claude.trust-dialog']) return 'TRUST'
if (conditions.conditions['claude.resume-prompt']) return 'RESUME'
if (conditions.conditions['claude.compaction']?.state.phase === 'error') return 'ERROR'
return null
const policy = getRendererProviderCapabilities(conditions.provider).conditionPolicy
for (const rule of policy.attentionLabels) {
const record = conditions.conditions[rule.kind]
if (!record) continue
const label =
typeof rule.label === 'function' ? rule.label(record.state) : rule.label
if (label) return label
}
if (conditions.conditions['codex.approval']) return 'ACTION'
if (conditions.conditions['codex.trust-dialog']) return 'TRUST'
return null
}

export function slashPickerFromConditions(
conditions: ProviderConditionSnapshot | null,
) {
if (conditions?.provider !== 'claude') return null
return conditions.conditions['claude.slash-picker']?.state ?? null
): ClaudeSlashPickerState | null {
if (!conditions) return null
const pickerKind = getRendererProviderCapabilities(conditions.provider)
.conditionPolicy.composerPickerKind
if (!pickerKind) return null
return conditionStateByKind<ClaudeSlashPickerState>(conditions, pickerKind)
}
Loading