Skip to content

Commit 0bb81d7

Browse files
committed
Add audit summary to run results and acceptance test suite
Introduce summarize_audit_events() that derives status, event counts, tool names, destructive call count, and approval state from audit logs. Include audit_summary in CLI and TUI run result payloads. Add acceptance test suite (tests/acceptance/) covering daily CLI read-only workflow, CLI prompt approval resume, TUI chat/memory/progress flow, and TUI prompt approval, gated by python3 -m pytest tests/acceptance.
1 parent 457c8cf commit 0bb81d7

7 files changed

Lines changed: 303 additions & 6 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ teaagent agent run gpt "Summarize the tests" --permission-mode read-only
181181
# Run tests
182182
pytest
183183

184+
# Run user-facing acceptance workflows
185+
python3 -m pytest tests/acceptance
186+
184187
# Lint
185188
ruff check .
186189
ruff format --check .

docs/acceptance.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Acceptance Coverage
2+
3+
TeaAgent acceptance tests live under `tests/acceptance/` and verify user-facing workflows rather than isolated primitives.
4+
5+
## Covered
6+
7+
- Daily CLI read-only workflow: `agent preflight`, `agent run`, `agent show`, audit persistence, and run-level audit summary.
8+
- CLI prompt approval workflow: destructive call pauses, `agent resume` auto-approves the pending call id, writes the file, and reports audit summary.
9+
- Daily TUI workflow: chat mode, memory injection, progress streaming, agent answer persistence in session history.
10+
- TUI prompt approval workflow: approval prompt, destructive write, final run payload, and audit summary.
11+
12+
## Next User Stories
13+
14+
- Live provider conformance gated by environment variables.
15+
- MCP client compatibility flow with auth, session lifecycle, `tools/list`, and `tools/call`.
16+
- A2A federation flow with remote discovery, capability routing, delegation, failure fallback, and audit correlation.
17+
- Managed runtime flow that forwards task context/tools and records managed runtime audit events.
18+
- Long-running worker flow covering `ultrawork start`, heartbeat/status, logs, and stop.
19+
- Workspace edit flow covering hash reads, patch application, git status, test execution, and final diff summary.

teaagent/cli/_handlers/_agent.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from teaagent.model_routing import route_model
1111
from teaagent.policy import parse_permission_mode
1212
from teaagent.preflight import preflight
13-
from teaagent.run_store import RunStore
13+
from teaagent.run_store import RunStore, summarize_audit_events
1414
from teaagent.runner import ApprovalRequest, RunResult
1515

1616

@@ -152,7 +152,12 @@ def _execute_agent_task(
152152

153153
with suppress(Exception):
154154
_telemetry_sink.force_flush()
155-
payload = run_result_payload(result, routing=routing.to_dict() if routing else None)
155+
events = store.show_run(result.run_id)
156+
payload = run_result_payload(
157+
result,
158+
routing=routing.to_dict() if routing else None,
159+
audit_summary=summarize_audit_events(events),
160+
)
156161
if resumed_from:
157162
payload['resumed_from'] = resumed_from
158163
payload['task'] = task
@@ -165,7 +170,10 @@ def _execute_agent_task(
165170

166171

167172
def run_result_payload(
168-
result: RunResult, *, routing: Optional[dict[str, Any]]
173+
result: RunResult,
174+
*,
175+
routing: Optional[dict[str, Any]],
176+
audit_summary: Optional[dict[str, Any]] = None,
169177
) -> dict[str, Any]:
170178
payload: dict[str, Any] = {
171179
'run_id': result.run_id,
@@ -175,6 +183,8 @@ def run_result_payload(
175183
'routing': routing,
176184
'final_answer': result.final_answer.content if result.final_answer else None,
177185
}
186+
if audit_summary is not None:
187+
payload['audit_summary'] = audit_summary
178188
if 'approval' in result.metadata:
179189
payload['approval'] = result.metadata['approval']
180190
return payload

teaagent/run_store.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,39 @@ def summarize(self, path: Path) -> Optional[RunSummary]:
206206

207207
def safe_run_id(run_id: str) -> str:
208208
return ''.join(ch for ch in run_id if ch.isalnum() or ch in {'-', '_'}) or 'run'
209+
210+
211+
def summarize_audit_events(events: list[dict[str, Any]]) -> dict[str, Any]:
212+
event_counts: dict[str, int] = {}
213+
tool_names: list[str] = []
214+
destructive_tool_calls = 0
215+
approval_required = False
216+
status = 'unknown'
217+
for event in events:
218+
event_type = str(event.get('event_type', ''))
219+
event_counts[event_type] = event_counts.get(event_type, 0) + 1
220+
payload = event.get('payload', {})
221+
if not isinstance(payload, dict):
222+
payload = {}
223+
if event_type == 'tool_call_started':
224+
tool_name = payload.get('tool_name')
225+
if isinstance(tool_name, str) and tool_name not in tool_names:
226+
tool_names.append(tool_name)
227+
annotations = payload.get('annotations', {})
228+
if isinstance(annotations, dict) and annotations.get('destructive'):
229+
destructive_tool_calls += 1
230+
if event_type == 'tool_call_pending_approval':
231+
approval_required = True
232+
if event_type == 'run_completed':
233+
status = 'completed'
234+
elif event_type == 'run_paused':
235+
status = str(payload.get('status', 'pending_approval'))
236+
elif event_type == 'run_failed':
237+
status = f'failed:{payload.get("category", "system")}'
238+
return {
239+
'status': status,
240+
'event_counts': event_counts,
241+
'tool_names': tool_names,
242+
'destructive_tool_calls': destructive_tool_calls,
243+
'approval_required': approval_required,
244+
}

teaagent/tui/__init__.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from teaagent.memory import MemoryCatalog
1717
from teaagent.model_routing import route_model
1818
from teaagent.policy import PermissionMode
19-
from teaagent.run_store import RunStore
19+
from teaagent.run_store import RunStore, summarize_audit_events
2020
from teaagent.runner import ApprovalRequest, RunResult
2121
from teaagent.session import ChatMessage, ChatSession, SessionStore
2222

@@ -256,6 +256,7 @@ def _run_agent_task(
256256
initial_observations=initial_observations,
257257
)
258258
store.logger_for_result(result, audit)
259+
audit_summary = summarize_audit_events(store.show_run(result.run_id))
259260

260261
if self.chat:
261262
chat_session: Optional[ChatSession] = self._current_session()
@@ -275,7 +276,9 @@ def _run_agent_task(
275276
self.output_fn(result.final_answer.content)
276277
else:
277278
payload = self._run_result_payload(
278-
result, routing=routing.to_dict() if routing else None
279+
result,
280+
routing=routing.to_dict() if routing else None,
281+
audit_summary=audit_summary,
279282
)
280283
if initial_observations:
281284
payload['replayed_observations'] = len(initial_observations)
@@ -295,7 +298,11 @@ def _approval_handler(self, request: ApprovalRequest) -> bool:
295298
return approved
296299

297300
def _run_result_payload(
298-
self, result: RunResult, *, routing: Optional[dict]
301+
self,
302+
result: RunResult,
303+
*,
304+
routing: Optional[dict],
305+
audit_summary: Optional[dict[str, Any]] = None,
299306
) -> dict:
300307
payload = {
301308
'run_id': result.run_id,
@@ -309,6 +316,8 @@ def _run_result_payload(
309316
}
310317
if 'approval' in result.metadata:
311318
payload['approval'] = result.metadata['approval']
319+
if audit_summary is not None:
320+
payload['audit_summary'] = audit_summary
312321
if result.error_message is not None:
313322
payload['error'] = result.error_message
314323
return payload

tests/acceptance/test_daily_cli.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import json
5+
import tempfile
6+
import unittest
7+
from contextlib import redirect_stdout
8+
from pathlib import Path
9+
from unittest.mock import patch
10+
11+
from conftest import FakeAdapter
12+
13+
from teaagent.cli import main
14+
15+
16+
class DailyCLIAcceptanceTests(unittest.TestCase):
17+
def test_daily_cli_read_only_run_preflight_and_audit_summary(self) -> None:
18+
with tempfile.TemporaryDirectory() as tmp:
19+
root = Path(tmp)
20+
(root / 'README.md').write_text('hello teaagent', encoding='utf-8')
21+
adapter = FakeAdapter(
22+
[
23+
'{"type":"tool","tool_name":"workspace_read_file","arguments":{"path":"README.md"},"call_id":"read-1"}',
24+
'{"type":"final","content":"repo summarized"}',
25+
]
26+
)
27+
28+
preflight_out = io.StringIO()
29+
with redirect_stdout(preflight_out):
30+
preflight_code = main(
31+
[
32+
'agent',
33+
'preflight',
34+
'gpt',
35+
'Summarize README.md for onboarding',
36+
'--root',
37+
tmp,
38+
'--permission-mode',
39+
'read-only',
40+
]
41+
)
42+
preflight_payload = json.loads(preflight_out.getvalue())
43+
44+
run_out = io.StringIO()
45+
with (
46+
patch('teaagent.cli.create_llm_adapter', return_value=adapter),
47+
redirect_stdout(run_out),
48+
):
49+
run_code = main(
50+
[
51+
'agent',
52+
'run',
53+
'gpt',
54+
'Summarize README.md for onboarding',
55+
'--root',
56+
tmp,
57+
'--permission-mode',
58+
'read-only',
59+
]
60+
)
61+
run_payload = json.loads(run_out.getvalue())
62+
63+
show_out = io.StringIO()
64+
with redirect_stdout(show_out):
65+
show_code = main(
66+
['agent', 'show', run_payload['run_id'], '--root', tmp]
67+
)
68+
events = json.loads(show_out.getvalue())
69+
70+
self.assertEqual(preflight_code, 0)
71+
self.assertTrue(preflight_payload['ready'])
72+
self.assertEqual(preflight_payload['permission_mode'], 'read-only')
73+
self.assertEqual(run_code, 0)
74+
self.assertEqual(run_payload['status'], 'completed')
75+
self.assertEqual(run_payload['final_answer'], 'repo summarized')
76+
self.assertEqual(run_payload['audit_summary']['status'], 'completed')
77+
self.assertEqual(
78+
run_payload['audit_summary']['tool_names'], ['workspace_read_file']
79+
)
80+
self.assertEqual(run_payload['audit_summary']['approval_required'], False)
81+
self.assertEqual(show_code, 0)
82+
self.assertIn('run_completed', [event['event_type'] for event in events])
83+
84+
def test_daily_cli_prompt_approval_resume_is_auditable(self) -> None:
85+
with tempfile.TemporaryDirectory() as tmp:
86+
first_adapter = FakeAdapter(
87+
[
88+
'{"type":"tool","tool_name":"workspace_write_file","arguments":{"path":"TODO.md","content":"done"},"call_id":"write-1"}'
89+
]
90+
)
91+
first_out = io.StringIO()
92+
with (
93+
patch('teaagent.cli.create_llm_adapter', return_value=first_adapter),
94+
redirect_stdout(first_out),
95+
):
96+
first_code = main(
97+
['agent', 'run', 'gpt', 'Create TODO.md', '--root', tmp]
98+
)
99+
first_payload = json.loads(first_out.getvalue())
100+
101+
resume_adapter = FakeAdapter(
102+
[
103+
'{"type":"tool","tool_name":"workspace_write_file","arguments":{"path":"TODO.md","content":"done"},"call_id":"write-1"}',
104+
'{"type":"final","content":"created todo"}',
105+
]
106+
)
107+
resume_out = io.StringIO()
108+
with (
109+
patch('teaagent.cli.create_llm_adapter', return_value=resume_adapter),
110+
redirect_stdout(resume_out),
111+
):
112+
resume_code = main(
113+
['agent', 'resume', 'gpt', first_payload['run_id'], '--root', tmp]
114+
)
115+
resume_payload = json.loads(resume_out.getvalue())
116+
117+
self.assertEqual(first_code, 1)
118+
self.assertEqual(first_payload['status'], 'pending_approval')
119+
self.assertTrue(first_payload['audit_summary']['approval_required'])
120+
self.assertEqual(first_payload['approval']['call_id'], 'write-1')
121+
self.assertEqual(resume_code, 0)
122+
self.assertEqual(resume_payload['status'], 'completed')
123+
self.assertEqual(resume_payload['resumed_from'], first_payload['run_id'])
124+
self.assertEqual(resume_payload['auto_approved_call_id'], 'write-1')
125+
self.assertEqual(
126+
resume_payload['audit_summary']['destructive_tool_calls'], 1
127+
)
128+
self.assertEqual(
129+
(Path(tmp) / 'TODO.md').read_text(encoding='utf-8'), 'done'
130+
)
131+
132+
133+
if __name__ == '__main__':
134+
unittest.main()

tests/acceptance/test_daily_tui.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import tempfile
5+
import unittest
6+
from pathlib import Path
7+
8+
from conftest import FakeAdapter
9+
10+
from teaagent.tui import TeaAgentTUI
11+
12+
13+
class DailyTUIAcceptanceTests(unittest.TestCase):
14+
def test_daily_tui_chat_memory_progress_and_audit_summary(self) -> None:
15+
with tempfile.TemporaryDirectory() as tmp:
16+
root = Path(tmp)
17+
(root / 'note.txt').write_text('hello', encoding='utf-8')
18+
output: list[str] = []
19+
adapter = FakeAdapter(
20+
[
21+
'{"type":"tool","tool_name":"workspace_read_file","arguments":{"path":"note.txt"},"call_id":"read-1"}',
22+
'{"type":"final","content":"note summarized"}',
23+
]
24+
)
25+
tui = TeaAgentTUI(
26+
root=tmp,
27+
input_fn=lambda _prompt: 'exit',
28+
output_fn=output.append,
29+
adapter_factory=lambda _provider, _model: adapter,
30+
)
31+
32+
self.assertTrue(tui.handle_command('chat on'))
33+
self.assertTrue(
34+
tui.handle_command(
35+
'memory add summarize note.txt Prefer concise summaries'
36+
)
37+
)
38+
self.assertTrue(tui.handle_command('progress on'))
39+
self.assertTrue(tui.handle_command('ask summarize note.txt'))
40+
self.assertTrue(tui.handle_command('session show'))
41+
42+
joined = '\n'.join(output)
43+
session_payload = json.loads(output[-1])
44+
45+
self.assertIn('chat: on', joined)
46+
self.assertIn('progress: on', joined)
47+
self.assertIn('tool: workspace_read_file', joined)
48+
self.assertIn('note summarized', output)
49+
self.assertEqual(len(session_payload['messages']), 2)
50+
self.assertIn(
51+
'Prefer concise summaries', adapter.requests[0].messages[0].content
52+
)
53+
54+
def test_daily_tui_prompt_approval_is_auditable(self) -> None:
55+
with tempfile.TemporaryDirectory() as tmp:
56+
output: list[str] = []
57+
adapter = FakeAdapter(
58+
[
59+
'{"type":"tool","tool_name":"workspace_write_file","arguments":{"path":"TODO.md","content":"done"},"call_id":"write-1"}',
60+
'{"type":"final","content":"created todo"}',
61+
]
62+
)
63+
tui = TeaAgentTUI(
64+
root=tmp,
65+
input_fn=lambda _prompt: 'yes',
66+
output_fn=output.append,
67+
adapter_factory=lambda _provider, _model: adapter,
68+
)
69+
70+
self.assertTrue(tui.handle_command('ask create TODO.md'))
71+
approval_payload = json.loads(output[1])
72+
result_payload = json.loads(output[-1])
73+
74+
self.assertEqual(approval_payload['status'], 'approval_required')
75+
self.assertEqual(result_payload['status'], 'completed')
76+
self.assertTrue(result_payload['audit_summary']['approval_required'])
77+
self.assertEqual(
78+
result_payload['audit_summary']['destructive_tool_calls'], 1
79+
)
80+
self.assertEqual(
81+
(Path(tmp) / 'TODO.md').read_text(encoding='utf-8'), 'done'
82+
)
83+
84+
85+
if __name__ == '__main__':
86+
unittest.main()

0 commit comments

Comments
 (0)