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
26 changes: 16 additions & 10 deletions apps/flutter/lib/src/client/t4_client_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,7 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
final choices = <ComposerModelChoice>[];
final seen = <String>{};
final slashCommands = <String, ComposerSlashCommand>{};
final operationCapabilities =
_catalogFrame?.operations ?? const <OperationCapability>[];
final operationCapabilities = _catalogFrame?.operations;
for (final item in _catalogItems) {
if (item.kind == 'model') {
final selector = modelItemSelector(item);
Expand All @@ -207,7 +206,11 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
);
continue;
}
if (item.kind != 'command' || operationCapabilities.isNotEmpty) continue;
if (item.kind != 'command' || operationCapabilities != null) continue;
final metadata = item.metadata ?? const <String, Object?>{};
if (!item.name.startsWith('/') && metadata['slashCommand'] != true) {
continue;
}
final bareName = item.name.replaceFirst(RegExp(r'^/+'), '');
final missingCapability = item.capabilities
?.where((capability) => !_grantedCapabilities.contains(capability))
Expand All @@ -225,7 +228,8 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
disabledReason: disabledReason,
);
}
for (final operation in operationCapabilities) {
for (final operation
in operationCapabilities ?? const <OperationCapability>[]) {
if (!operation.operationId.startsWith('slash.')) continue;
final bareName = operation.operationId.substring('slash.'.length);
if (bareName.isEmpty) continue;
Expand All @@ -241,8 +245,12 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
.map((alias) => '/${alias.replaceFirst(RegExp(r'^/+'), '')}')
.toList(growable: false)
: const <String>[];
final missingCapability = operation.capabilities
?.where((capability) => !_grantedCapabilities.contains(capability))
final requiredCapabilities = <String>{
'sessions.prompt',
...?operation.capabilities,
};
final missingCapability = requiredCapabilities
.where((capability) => !_grantedCapabilities.contains(capability))
.firstOrNull;
String? disabledReason;
if (!operation.supported) {
Expand All @@ -252,11 +260,9 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
disabledReason = missingCapability == 'terminal.io'
? 'Needs terminal access on this host'
: 'Not granted on this host';
} else if ((session.turnActive || _submitting) &&
bareName == 'compact') {
} else if ((session.turnActive || _submitting) && bareName == 'compact') {
disabledReason = 'Wait for the turn to finish';
} else if ((session.turnActive || _submitting) &&
bareName == 'retry') {
} else if ((session.turnActive || _submitting) && bareName == 'retry') {
disabledReason = 'A turn is already running';
}
slashCommands[name] = ComposerSlashCommand(
Expand Down
8 changes: 4 additions & 4 deletions apps/flutter/lib/src/protocol/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -585,12 +585,12 @@ final class CatalogResult {
const CatalogResult({
required this.revision,
required this.items,
this.operations = const <OperationCapability>[],
this.operations,
});

final String revision;
final List<CatalogItem> items;
final List<OperationCapability> operations;
final List<OperationCapability>? operations;
}

final class SettingsResult {
Expand Down Expand Up @@ -1235,14 +1235,14 @@ final class CatalogFrame extends WireFrame {
required this.hostId,
required this.revision,
required this.items,
this.operations = const <OperationCapability>[],
this.operations,
required super.raw,
});

final String hostId;
final String revision;
final List<CatalogItem> items;
final List<OperationCapability> operations;
final List<OperationCapability>? operations;
}

final class SettingsFrame extends WireFrame {
Expand Down
4 changes: 2 additions & 2 deletions apps/flutter/lib/src/protocol/wire_decoder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1299,12 +1299,12 @@ OperationCapability _operationCapability(Object? value, String path) {
);
}

List<OperationCapability> _operationCapabilities(
List<OperationCapability>? _operationCapabilities(
Map<String, Object?> raw,
String path,
) {
if (!raw.containsKey('operations')) {
return const <OperationCapability>[];
return null;
}
final values = _list(raw['operations'], path);
return List<OperationCapability>.unmodifiable([
Expand Down
1 change: 1 addition & 0 deletions apps/flutter/lib/src/ui/conversation_pane.dart
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ final class _PromptComposerState extends State<_PromptComposer> {
bool get _ready =>
widget.state.connectionPhase == ConnectionPhase.ready &&
widget.state.selectedSession != null &&
widget.state.grantedCapabilities.contains('sessions.prompt') &&
!_sending;

bool get _canSubmit =>
Expand Down
81 changes: 80 additions & 1 deletion apps/flutter/test/client/t4_client_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,6 @@ void main() {
'description': 'Compact the active conversation',
'execution': 'headless',
'supported': true,
'capabilities': <Object?>['sessions.prompt'],
'metadata': <String, Object?>{
'aliases': <Object?>['compress'],
},
Expand Down Expand Up @@ -553,6 +552,86 @@ void main() {
commands.last.disabledReason,
'/plan requires the OMP terminal interface.',
);

channel.emit(<String, Object?>{
'v': 'omp-app/1',
'type': 'catalog',
'hostId': 'host-alpha',
'revision': 'catalog-authoritative-empty',
'items': <Object?>[
<String, Object?>{
'id': 'command:legacy-compact',
'kind': 'command',
'name': '/compact',
},
<String, Object?>{
'id': 'command:session.cancel',
'kind': 'command',
'name': 'session.cancel',
},
],
'operations': <Object?>[],
});
await _flush();
expect(controller.state.composer.slashCommands, isEmpty);
},
);

test(
'read-only catalog clients see official headless commands disabled',
() async {
final profile = _profile('alpha');
final connector = _FakeConnector();
final controller = _controller(
_MemoryDirectoryStore(
directory: const HostDirectory.empty().upsert(profile),
),
_MemoryCredentialStore(),
connector,
);
addTearDown(controller.dispose);
await controller.initialize();
final channel = connector.channels.single;

channel.emit(
_welcome(
'host-alpha',
capabilities: const <String>['sessions.read', 'catalog.read'],
features: const <String>['catalog.metadata'],
),
);
await _flush();
final list = channel.sentJson.firstWhere(
(frame) => frame['command'] == 'session.list',
);
channel.emit(
_response(
list,
command: 'session.list',
result: _sessionListResult('host-alpha'),
),
);
channel.emit(<String, Object?>{
'v': 'omp-app/1',
'type': 'catalog',
'hostId': 'host-alpha',
'revision': 'catalog-read-only',
'items': <Object?>[],
'operations': <Object?>[
<String, Object?>{
'operationId': 'slash.compact',
'label': '/compact',
'execution': 'headless',
'supported': true,
},
],
});
await _flush();

expect(
controller.state.composer.slashCommands.single.disabledReason,
'Not granted on this host',
);
},
);

Expand Down
33 changes: 29 additions & 4 deletions apps/flutter/test/protocol/wire_conformance_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ void main() {

expect(result.revision, 'capabilities-v1');
expect(
result.operations.map((operation) => operation.operationId),
result.operations!.map((operation) => operation.operationId),
<String>[
'session.prompt',
'slash.compact',
Expand All @@ -216,7 +216,7 @@ void main() {
],
);
expect(
result.operations.map((operation) => operation.execution),
result.operations!.map((operation) => operation.execution),
<OperationExecution>[
OperationExecution.typed,
OperationExecution.headless,
Expand All @@ -225,13 +225,13 @@ void main() {
],
);
expect(
result.operations
result.operations!
.skip(2)
.map((operation) => operation.disabledReason!.code),
<String>['terminal_only', 'capability_unavailable'],
);
expect(
() => result.operations.add(result.operations.first),
() => result.operations!.add(result.operations!.first),
throwsUnsupportedError,
);

Expand All @@ -245,6 +245,31 @@ void main() {
]);
});

test(
'catalog decoding preserves missing versus authoritative empty operations',
() {
Map<String, Object?> catalog([List<Object?>? operations]) {
final frame = <String, Object?>{
'v': 'omp-app/1',
'type': 'catalog',
'hostId': 'host-alpha',
'revision': operations == null ? 'legacy' : 'authoritative-empty',
'items': <Object?>[],
};
if (operations != null) frame['operations'] = operations;
return frame;
}

final legacy =
WireDecoder.decode(jsonEncode(catalog())) as CatalogFrame;
final authoritative =
WireDecoder.decode(jsonEncode(catalog(<Object?>[])))
as CatalogFrame;
expect(legacy.operations, isNull);
expect(authoritative.operations, isEmpty);
},
);

test(
'every non-corpus ServerFrame branch rejects a missing requirement',
() {
Expand Down
41 changes: 41 additions & 0 deletions apps/flutter/test/ui/host_flow_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,47 @@ void main() {
expect(actions.submittedPrompts, <String>['Alpha draft']);
},
);
testWidgets(
'composer stays disabled when prompt permission was not granted',
(tester) async {
final profile = HostProfile.parseTailnetAddress(
'https://alpha.tailnet-name.ts.net',
);
final state = T4ViewState(
connectionPhase: ConnectionPhase.ready,
hostDirectory: HostDirectory.empty().upsert(profile),
authenticationPhase: AuthenticationPhase.paired,
grantedCapabilities: const <String>{'sessions.read', 'catalog.read'},
selectedSessionId: 'session-alpha',
sessions: const <SessionSummary>[
SessionSummary(
hostId: 'host-alpha',
sessionId: 'session-alpha',
projectId: 'project-alpha',
projectName: 'Project Alpha',
title: 'Read-only session',
revision: 'revision-alpha',
status: 'idle',
),
],
);

await pumpApp(
tester,
state: state,
actions: _FakeActions(),
size: compactPhone,
);
await tester.enterText(find.byType(TextField).last, 'Cannot send');
await tester.pump();
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Send'))
.onPressed,
isNull,
);
},
);
testWidgets(
'Fast toggle reads as an inactive toggle when available and off, and selected when on',
(tester) async {
Expand Down
14 changes: 8 additions & 6 deletions apps/web/src/features/composer/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function slashCommandsFromCatalog(
items: readonly CatalogItem[],
context: SlashCatalogContext,
granted: readonly string[],
operations: readonly OperationCapability[] = [],
operations?: readonly OperationCapability[],
): SlashCommand[] {
const offlineReason =
context.link === "cached"
Expand All @@ -55,12 +55,13 @@ export function slashCommandsFromCatalog(
// Fall back to legacy command items only when an older host does not expose
// the new contract at all; otherwise typed commands such as session.cancel
// would be mistaken for slash commands.
if (operations.length === 0) {
if (operations === undefined) {
for (const item of items) {
if (item.kind !== "command") continue;
const metadata = item.metadata ?? {};
if (!item.name.startsWith("/") && metadata.slashCommand !== true) continue;
const bareName = item.name.replace(/^\/+/, "");
const name = `/${bareName}`;
const metadata = item.metadata ?? {};
const rawAliases = Array.isArray(metadata.aliases) ? metadata.aliases : [];
const aliases = rawAliases
.filter((alias): alias is string => typeof alias === "string" && alias !== "")
Expand Down Expand Up @@ -93,7 +94,7 @@ export function slashCommandsFromCatalog(
});
}
}
for (const operation of operations) {
for (const operation of operations ?? []) {
const operationId = String(operation.operationId);
if (!operationId.startsWith("slash.")) continue;
const bareName = operationId.slice("slash.".length);
Expand All @@ -107,7 +108,8 @@ export function slashCommandsFromCatalog(
const aliases = rawAliases
.filter((alias): alias is string => typeof alias === "string" && alias !== "")
.map((alias) => `/${alias.replace(/^\/+/, "")}`);
const missingCapability = (operation.capabilities ?? []).find(
const requiredCapabilities = ["sessions.prompt", ...(operation.capabilities ?? [])];
const missingCapability = requiredCapabilities.find(
(capability) => !granted.includes(capability),
);
const disabledReason =
Expand All @@ -128,7 +130,7 @@ export function slashCommandsFromCatalog(
name,
aliases,
description: operation.description ?? "",
argsHint: "",
argsHint: typeof metadata.inlineHint === "string" ? metadata.inlineHint : "",
disabledReason,
insert: `${name} `,
});
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/session-runtime/live-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1215,7 +1215,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
readOnlyReason: controlGate === null ? null : controlGate.slashReason,
},
granted,
catalog.operations ?? [],
catalog.operations,
),
contextUsedTokens: contextUsage?.used ?? 0,
contextWindowTokens: contextUsage?.limit ?? 0,
Expand Down
Loading
Loading