Scaffold agent shell package
This commit is contained in:
commit
09080ca7f6
18 changed files with 738 additions and 0 deletions
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
coverage/
|
||||
pubspec.lock
|
||||
|
||||
# IDE/local
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# Generated by local experiments
|
||||
.tmp/
|
||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# agent-shell 프로젝트 규칙
|
||||
|
||||
## 응답 언어
|
||||
|
||||
- 기본 응답은 한국어로 한다.
|
||||
- 코드, 식별자, 명령어, 에러 메시지는 원문 표기를 유지한다.
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
`agent-shell`은 제품 앱이 아니라 Flutter 앱들이 공통으로 가져다 쓰는 agent interaction package다.
|
||||
채팅 메시지, tool call 표시, 승인 UI, capability registry, host integration boundary를 제공하되 IOP, NomadCode, OTO 같은 제품 도메인을 직접 알지 않는다.
|
||||
|
||||
## 작업 규칙
|
||||
|
||||
- `lib/` 안에는 `MaterialApp`, `runApp`, Firebase/SystemChrome 같은 application bootstrap을 넣지 않는다.
|
||||
- 제품별 의존성(`iop_*`, `nomadcode_*`)은 이 패키지에 import하지 않는다.
|
||||
- 백엔드 transport, 인증, endpoint, theme, navigation은 host 앱이 주입한다.
|
||||
- Agent capability는 interface와 event/command model만 제공하고, 제품별 capability pack은 각 제품 repo나 optional composition package가 소유한다.
|
||||
- 새 public API를 추가하면 `lib/agent_shell.dart` export와 README 예시를 함께 확인한다.
|
||||
- UI 위젯은 host layout에 embedded될 수 있게 fixed app chrome을 전제하지 않는다.
|
||||
- 테스트는 변경 범위에 맞춰 `flutter test`를 우선 실행한다.
|
||||
|
||||
## 진입 파일
|
||||
|
||||
- `README.md`
|
||||
- `pubspec.yaml`
|
||||
- `lib/agent_shell.dart`
|
||||
- `agent-ops/rules/project/rules.md`
|
||||
70
README.md
Normal file
70
README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# agent-shell
|
||||
|
||||
`agent-shell` is a Flutter package for embeddable agent interaction surfaces.
|
||||
It is not a product app. It provides the shared shell that IOP, NomadCode, and future host apps can compose with their own capability packs.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Render agent messages, tool calls, and approval prompts.
|
||||
- Provide capability and event interfaces that host apps can wire to their own backends.
|
||||
- Stay product-agnostic: no IOP, NomadCode, WebView, workflow, or Control Plane dependency lives here.
|
||||
- Fit into different workbench layouts, including NomadCode's right rail and IOP's left rail.
|
||||
|
||||
## Package Boundary
|
||||
|
||||
```text
|
||||
agent_shell
|
||||
AgentShell widget
|
||||
AgentMessage / AgentToolCall models
|
||||
AgentCapability / AgentCommand / AgentEvent interfaces
|
||||
Tool call and approval UI primitives
|
||||
|
||||
iop_console_flutter
|
||||
IOP-specific capability pack
|
||||
IOP Control Plane / Edge / Node integration
|
||||
|
||||
nomadcode_app
|
||||
NomadCode workbench shell
|
||||
NomadCode WebView/workflow capability pack
|
||||
optional IOP composition package
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```dart
|
||||
AgentShell(
|
||||
messages: messages,
|
||||
busy: isRunning,
|
||||
onSubmit: (text) => agentRuntime.send(text),
|
||||
onToolAction: (action) => agentRuntime.handleToolAction(action),
|
||||
)
|
||||
```
|
||||
|
||||
## Intended Composition
|
||||
|
||||
IOP standalone:
|
||||
|
||||
```text
|
||||
iop/apps/client
|
||||
-> iop_console_flutter
|
||||
-> agent_shell
|
||||
```
|
||||
|
||||
NomadCode without IOP:
|
||||
|
||||
```text
|
||||
nomadcode/apps/client
|
||||
-> nomadcode workbench shell
|
||||
-> agent_shell
|
||||
```
|
||||
|
||||
NomadCode with IOP:
|
||||
|
||||
```text
|
||||
nomadcode optional composition layer
|
||||
-> nomadcode workbench shell
|
||||
-> iop_console_flutter
|
||||
-> agent_shell
|
||||
```
|
||||
|
||||
The IOP import must stay in the optional composition layer so the default NomadCode build can succeed without IOP dependencies.
|
||||
32
agent-ops/rules/project/rules.md
Normal file
32
agent-ops/rules/project/rules.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# agent-shell project rules
|
||||
|
||||
## 응답 언어
|
||||
|
||||
- 기본 응답은 한국어로 한다.
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
`agent-shell`은 Flutter 기반 agent interaction package다.
|
||||
제품 앱, backend, runtime, WebView 조작, IOP Control Plane, NomadCode workflow를 직접 소유하지 않고, host 앱이 주입하는 메시지와 capability를 표시하고 상호작용하는 공통 UI/인터페이스만 제공한다.
|
||||
|
||||
## 주요 구조
|
||||
|
||||
- `lib/agent_shell.dart` - public export entrypoint.
|
||||
- `lib/src/domain/` - message, tool call, command, event, capability model.
|
||||
- `lib/src/widgets/` - embeddable chat surface와 UI primitives.
|
||||
- `test/` - widget/model tests.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- Flutter package
|
||||
- Dart SDK `^3.11.0`
|
||||
- `flutter_test`
|
||||
|
||||
## 프로젝트 특화 컨벤션
|
||||
|
||||
- `lib/`는 package 코드만 둔다. `runApp`, `MaterialApp`, Firebase/SystemChrome bootstrap은 host 앱 책임이다.
|
||||
- 제품별 package를 import하지 않는다. IOP/NomadCode 연결은 각 제품의 capability pack 또는 optional composition layer에서 구현한다.
|
||||
- Public API는 `lib/agent_shell.dart`에서 export한다.
|
||||
- Host가 theme, navigation, transport, auth, endpoint, persistence를 주입한다.
|
||||
- UI는 좌측 rail, 우측 rail, center content, modal 등 다양한 host layout에 들어갈 수 있어야 한다.
|
||||
- 변경 후 가능한 경우 `flutter test`를 실행한다.
|
||||
6
analysis_options.yaml
Normal file
6
analysis_options.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
prefer_final_fields: true
|
||||
10
lib/agent_shell.dart
Normal file
10
lib/agent_shell.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export 'src/domain/agent_capability.dart';
|
||||
export 'src/domain/agent_command.dart';
|
||||
export 'src/domain/agent_event.dart';
|
||||
export 'src/domain/agent_message.dart';
|
||||
export 'src/domain/agent_tool_call.dart';
|
||||
export 'src/widgets/agent_composer.dart';
|
||||
export 'src/widgets/agent_message_list.dart';
|
||||
export 'src/widgets/agent_shell.dart';
|
||||
export 'src/widgets/approval_prompt.dart';
|
||||
export 'src/widgets/tool_call_card.dart';
|
||||
35
lib/src/domain/agent_capability.dart
Normal file
35
lib/src/domain/agent_capability.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import 'agent_command.dart';
|
||||
import 'agent_event.dart';
|
||||
|
||||
abstract interface class AgentCapability {
|
||||
String get id;
|
||||
String get label;
|
||||
String get category;
|
||||
|
||||
Future<AgentCapabilityDescriptor> describe();
|
||||
|
||||
Stream<AgentEvent> invoke(AgentCommand command, AgentContext context);
|
||||
}
|
||||
|
||||
class AgentCapabilityDescriptor {
|
||||
final String id;
|
||||
final String label;
|
||||
final String category;
|
||||
final String description;
|
||||
final bool requiresApproval;
|
||||
|
||||
const AgentCapabilityDescriptor({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.category,
|
||||
this.description = '',
|
||||
this.requiresApproval = false,
|
||||
});
|
||||
}
|
||||
|
||||
class AgentContext {
|
||||
final String? conversationId;
|
||||
final Map<String, Object?> values;
|
||||
|
||||
const AgentContext({this.conversationId, this.values = const {}});
|
||||
}
|
||||
22
lib/src/domain/agent_command.dart
Normal file
22
lib/src/domain/agent_command.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
class AgentCommand {
|
||||
final String id;
|
||||
final String text;
|
||||
final String? capabilityId;
|
||||
final Map<String, Object?> payload;
|
||||
|
||||
const AgentCommand({
|
||||
required this.id,
|
||||
required this.text,
|
||||
this.capabilityId,
|
||||
this.payload = const {},
|
||||
});
|
||||
}
|
||||
|
||||
class AgentToolAction {
|
||||
final String toolCallId;
|
||||
final AgentToolActionType type;
|
||||
|
||||
const AgentToolAction({required this.toolCallId, required this.type});
|
||||
}
|
||||
|
||||
enum AgentToolActionType { approve, reject, cancel, retry }
|
||||
32
lib/src/domain/agent_event.dart
Normal file
32
lib/src/domain/agent_event.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import 'agent_message.dart';
|
||||
import 'agent_tool_call.dart';
|
||||
|
||||
sealed class AgentEvent {
|
||||
const AgentEvent();
|
||||
}
|
||||
|
||||
class AgentMessageEvent extends AgentEvent {
|
||||
final AgentMessage message;
|
||||
|
||||
const AgentMessageEvent(this.message);
|
||||
}
|
||||
|
||||
class AgentToolCallEvent extends AgentEvent {
|
||||
final AgentToolCall toolCall;
|
||||
|
||||
const AgentToolCallEvent(this.toolCall);
|
||||
}
|
||||
|
||||
class AgentStatusEvent extends AgentEvent {
|
||||
final String label;
|
||||
final bool busy;
|
||||
|
||||
const AgentStatusEvent({required this.label, this.busy = false});
|
||||
}
|
||||
|
||||
class AgentErrorEvent extends AgentEvent {
|
||||
final String message;
|
||||
final Object? details;
|
||||
|
||||
const AgentErrorEvent(this.message, {this.details});
|
||||
}
|
||||
19
lib/src/domain/agent_message.dart
Normal file
19
lib/src/domain/agent_message.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'agent_tool_call.dart';
|
||||
|
||||
enum AgentMessageRole { user, assistant, system, tool }
|
||||
|
||||
class AgentMessage {
|
||||
final String id;
|
||||
final AgentMessageRole role;
|
||||
final String text;
|
||||
final DateTime? createdAt;
|
||||
final List<AgentToolCall> toolCalls;
|
||||
|
||||
const AgentMessage({
|
||||
required this.id,
|
||||
required this.role,
|
||||
required this.text,
|
||||
this.createdAt,
|
||||
this.toolCalls = const [],
|
||||
});
|
||||
}
|
||||
30
lib/src/domain/agent_tool_call.dart
Normal file
30
lib/src/domain/agent_tool_call.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
enum AgentToolCallStatus {
|
||||
pending,
|
||||
awaitingApproval,
|
||||
running,
|
||||
succeeded,
|
||||
failed,
|
||||
canceled,
|
||||
}
|
||||
|
||||
class AgentToolCall {
|
||||
final String id;
|
||||
final String name;
|
||||
final AgentToolCallStatus status;
|
||||
final String summary;
|
||||
final Map<String, Object?> arguments;
|
||||
final String? resultText;
|
||||
final String? errorText;
|
||||
|
||||
const AgentToolCall({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.status,
|
||||
this.summary = '',
|
||||
this.arguments = const {},
|
||||
this.resultText,
|
||||
this.errorText,
|
||||
});
|
||||
|
||||
bool get needsApproval => status == AgentToolCallStatus.awaitingApproval;
|
||||
}
|
||||
76
lib/src/widgets/agent_composer.dart
Normal file
76
lib/src/widgets/agent_composer.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AgentComposer extends StatefulWidget {
|
||||
final ValueChanged<String>? onSubmit;
|
||||
final bool enabled;
|
||||
final bool busy;
|
||||
final String placeholder;
|
||||
|
||||
const AgentComposer({
|
||||
super.key,
|
||||
this.onSubmit,
|
||||
this.enabled = true,
|
||||
this.busy = false,
|
||||
this.placeholder = 'Message the agent',
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgentComposer> createState() => _AgentComposerState();
|
||||
}
|
||||
|
||||
class _AgentComposerState extends State<AgentComposer> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || !widget.enabled || widget.onSubmit == null) return;
|
||||
widget.onSubmit!(text);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.placeholder,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
tooltip: widget.busy ? 'Agent is running' : 'Send',
|
||||
onPressed: widget.enabled ? _submit : null,
|
||||
icon: widget.busy
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/src/widgets/agent_message_list.dart
Normal file
94
lib/src/widgets/agent_message_list.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../domain/agent_command.dart';
|
||||
import '../domain/agent_message.dart';
|
||||
import 'tool_call_card.dart';
|
||||
|
||||
class AgentMessageList extends StatelessWidget {
|
||||
final List<AgentMessage> messages;
|
||||
final ValueChanged<AgentToolAction>? onToolAction;
|
||||
final Widget? emptyState;
|
||||
|
||||
const AgentMessageList({
|
||||
super.key,
|
||||
required this.messages,
|
||||
this.onToolAction,
|
||||
this.emptyState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (messages.isEmpty) {
|
||||
return Center(
|
||||
child:
|
||||
emptyState ??
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: messages.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return _AgentMessageBubble(
|
||||
message: message,
|
||||
onToolAction: onToolAction,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgentMessageBubble extends StatelessWidget {
|
||||
final AgentMessage message;
|
||||
final ValueChanged<AgentToolAction>? onToolAction;
|
||||
|
||||
const _AgentMessageBubble({required this.message, this.onToolAction});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isUser = message.role == AgentMessageRole.user;
|
||||
final alignment = isUser ? Alignment.centerRight : Alignment.centerLeft;
|
||||
final color = isUser
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest;
|
||||
final textColor = isUser
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return Align(
|
||||
alignment: alignment,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
message.text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: textColor),
|
||||
),
|
||||
for (final toolCall in message.toolCalls) ...[
|
||||
const SizedBox(height: 10),
|
||||
ToolCallCard(toolCall: toolCall, onAction: onToolAction),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
lib/src/widgets/agent_shell.dart
Normal file
47
lib/src/widgets/agent_shell.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../domain/agent_command.dart';
|
||||
import '../domain/agent_message.dart';
|
||||
import 'agent_composer.dart';
|
||||
import 'agent_message_list.dart';
|
||||
|
||||
class AgentShell extends StatelessWidget {
|
||||
final List<AgentMessage> messages;
|
||||
final ValueChanged<String>? onSubmit;
|
||||
final ValueChanged<AgentToolAction>? onToolAction;
|
||||
final bool busy;
|
||||
final String placeholder;
|
||||
final Widget? emptyState;
|
||||
|
||||
const AgentShell({
|
||||
super.key,
|
||||
required this.messages,
|
||||
this.onSubmit,
|
||||
this.onToolAction,
|
||||
this.busy = false,
|
||||
this.placeholder = 'Message the agent',
|
||||
this.emptyState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AgentMessageList(
|
||||
messages: messages,
|
||||
onToolAction: onToolAction,
|
||||
emptyState: emptyState,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
AgentComposer(
|
||||
enabled: !busy && onSubmit != null,
|
||||
busy: busy,
|
||||
placeholder: placeholder,
|
||||
onSubmit: onSubmit,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/src/widgets/approval_prompt.dart
Normal file
45
lib/src/widgets/approval_prompt.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../domain/agent_command.dart';
|
||||
import '../domain/agent_tool_call.dart';
|
||||
|
||||
class ApprovalPrompt extends StatelessWidget {
|
||||
final AgentToolCall toolCall;
|
||||
final ValueChanged<AgentToolAction>? onAction;
|
||||
|
||||
const ApprovalPrompt({super.key, required this.toolCall, this.onAction});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: onAction == null
|
||||
? null
|
||||
: () => onAction!(
|
||||
AgentToolAction(
|
||||
toolCallId: toolCall.id,
|
||||
type: AgentToolActionType.reject,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('Reject'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: onAction == null
|
||||
? null
|
||||
: () => onAction!(
|
||||
AgentToolAction(
|
||||
toolCallId: toolCall.id,
|
||||
type: AgentToolActionType.approve,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Approve'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/src/widgets/tool_call_card.dart
Normal file
99
lib/src/widgets/tool_call_card.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../domain/agent_command.dart';
|
||||
import '../domain/agent_tool_call.dart';
|
||||
import 'approval_prompt.dart';
|
||||
|
||||
class ToolCallCard extends StatelessWidget {
|
||||
final AgentToolCall toolCall;
|
||||
final ValueChanged<AgentToolAction>? onAction;
|
||||
|
||||
const ToolCallCard({super.key, required this.toolCall, this.onAction});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_statusIcon(toolCall.status), size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
toolCall.name,
|
||||
style: theme.textTheme.titleSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
_StatusLabel(status: toolCall.status),
|
||||
],
|
||||
),
|
||||
if (toolCall.summary.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(toolCall.summary),
|
||||
],
|
||||
if (toolCall.arguments.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
toolCall.arguments.toString(),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (toolCall.resultText case final result?) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(result, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
if (toolCall.errorText case final error?) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (toolCall.needsApproval) ...[
|
||||
const SizedBox(height: 12),
|
||||
ApprovalPrompt(toolCall: toolCall, onAction: onAction),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusLabel extends StatelessWidget {
|
||||
final AgentToolCallStatus status;
|
||||
|
||||
const _StatusLabel({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(switch (status) {
|
||||
AgentToolCallStatus.pending => 'Pending',
|
||||
AgentToolCallStatus.awaitingApproval => 'Approval',
|
||||
AgentToolCallStatus.running => 'Running',
|
||||
AgentToolCallStatus.succeeded => 'Done',
|
||||
AgentToolCallStatus.failed => 'Failed',
|
||||
AgentToolCallStatus.canceled => 'Canceled',
|
||||
}, style: Theme.of(context).textTheme.labelSmall);
|
||||
}
|
||||
}
|
||||
|
||||
IconData _statusIcon(AgentToolCallStatus status) {
|
||||
return switch (status) {
|
||||
AgentToolCallStatus.pending => Icons.schedule,
|
||||
AgentToolCallStatus.awaitingApproval => Icons.verified_user,
|
||||
AgentToolCallStatus.running => Icons.sync,
|
||||
AgentToolCallStatus.succeeded => Icons.check_circle,
|
||||
AgentToolCallStatus.failed => Icons.error,
|
||||
AgentToolCallStatus.canceled => Icons.cancel,
|
||||
};
|
||||
}
|
||||
19
pubspec.yaml
Normal file
19
pubspec.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
name: agent_shell
|
||||
description: Product-agnostic Flutter widgets and interfaces for embeddable agent interaction surfaces.
|
||||
version: 0.1.0
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
61
test/agent_shell_test.dart
Normal file
61
test/agent_shell_test.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'package:agent_shell/agent_shell.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('submits composer text', (tester) async {
|
||||
String? submitted;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AgentShell(
|
||||
messages: const [],
|
||||
onSubmit: (text) => submitted = text,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'hello');
|
||||
await tester.tap(find.byIcon(Icons.send));
|
||||
await tester.pump();
|
||||
|
||||
expect(submitted, 'hello');
|
||||
});
|
||||
|
||||
testWidgets('emits approval action for tool calls', (tester) async {
|
||||
AgentToolAction? action;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AgentShell(
|
||||
messages: const [
|
||||
AgentMessage(
|
||||
id: 'm1',
|
||||
role: AgentMessageRole.assistant,
|
||||
text: 'I need approval.',
|
||||
toolCalls: [
|
||||
AgentToolCall(
|
||||
id: 'tool-1',
|
||||
name: 'restart-node',
|
||||
status: AgentToolCallStatus.awaitingApproval,
|
||||
summary: 'Restart a node',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onToolAction: (next) => action = next,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Approve'));
|
||||
await tester.pump();
|
||||
|
||||
expect(action?.toolCallId, 'tool-1');
|
||||
expect(action?.type, AgentToolActionType.approve);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue