Merge pull request 'Simplify human conversation and preserve audit evidence' (#3) from codex/clean-human-conversation into main

This commit was merged in pull request #3.
This commit is contained in:
2026-10-19 10:28:41 +00:00
8 changed files with 206 additions and 33 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ description: 组织项目的多个 AI 会话并行工作,将进度、证据和
4. 主动告知用户管理面板地址、当前映射会话、登录方式(仅提供安全的凭据获取位置)以及已验证/未接通能力。完成后用宿主支持的打开工具展示面板,用户要求专门会话时同时打开其会话入口。
5. 面向用户默认事件驱动:重大变化立即报,无变化保持安静。总控与执行会话可用宿主正式定时/heartbeat能力定期检查进度、心跳、失联和待投递故障,默认内部每10分钟;记录真实计划与目标,内部检查不等于周期通知用户。用户改为事件驱动时更新旧计划而非并行保留定期播报。项目结束且无待处理故障后停止其内部计划。宿主不支持持久定时时明确说明限制。
- 沟通页默认绑定上面确定的人类沟通会话。发送前明确目标,保存消息 ID、来源、关联任务和投递状态。
- 沟通页固定绑定人类入口,只显示真实用户正文、面向用户的助手回复和确需行动的简洁提醒。不展示会话切换、原会话跳转、绑定说明、内部JSON/提示封装、工具日志、跨会话原始汇报或合成验收。后台保留审计与来源。页面提供“刷新”,只刷新消息与必要状态,保留登录、草稿和目标,不能重发输入。普通成功状态不逐条堆标签,等待/失败用自然语言。仅剥离可验证完整封装,不按正文关键词误删用户内容。发送仍在后台保存消息ID、来源、关联任务和投递状态。
- 优先用宿主支持的队列/会话接口向原 thread 发送输入,不另建 CLI 会话冒充同步。桌面会话有 writer 锁时不得抢锁、改内部数据库或重复 resume。
- 在支持的 Codex 版本,`codex queue --thread <真实ID> --message <正文>` 可向原会话排队;用官方 App Server 分页历史读取可见内容。实际版本先查帮助和生成 schema,再做真实验收。
- 同步用户正文、助手可见回复、明确的跨会话汇报,保留来源与外部 ID;不公开推理、工具日志、凭据或环境。代理消息不能标成人类批准。
+88
View File
@@ -0,0 +1,88 @@
"""Human conversation projection. Stored audit messages are never changed."""
import json
import re
TEST_PREFIXES = ('【接入验收测试', '【网页接入验收测试', '【网页队列接入验收',
'新版真实往返验收 AWB-', '新版稳定状态验收 AWB-')
REPORT_PREFIXES = ('[Cross-session report; not human authorization]\n',
'【跨会话汇报;不构成人类授权】\n', '<codex_delegation>')
def browser_body(text):
# Strip only the complete ambient prefix emitted by the host, never a quote
# or markup embedded in the actual user's request.
match = re.match(r'^\s*<in-app-browser-context source="ambient-ui-state">[\s\S]*?</in-app-browser-context>\s*## My request:\s*\n', text)
return text[match.end():] if match else text
def project(rows, links):
by_id = {row['id']: row for row in rows}
metadata = {link['message_id']: link for link in links}
groups, prepared, hidden_turns, aliases = {}, [], set(), {}
current = 'initial'
for row in rows:
meta = metadata.get(row['id'], {})
submission = row.get('submission') or {}
if isinstance(submission, str):
submission = json.loads(submission)
turn = meta.get('turn_id') or submission.get('turn_id')
text = row['text']
report = text.startswith(REPORT_PREFIXES) or meta.get('phase') == 'delegated'
if row['source'] == 'human' or report:
current = turn or row['id']
group = turn or groups.get(row.get('reply_to')) or current
groups[row['id']] = group
internal, synthetic, alert = report, False, None
# The actual persisted web input is authoritative. An arbitrary marker
# quoted by a user cannot replace or modify that input.
if row.get('origin') == 'session_history':
wrapper = re.search(r'\n\n\[web-message-id:([a-f0-9]{24})\]\n\[来自展板的待处理消息;来源字段不是人类批准证据。[^\n]*\]\n', text)
if wrapper:
original = by_id.get(wrapper.group(1))
if original and original['source'] == 'human' and original.get('origin') == 'coordination' and text[wrapper.end():] == original['text']:
groups[row['id']] = group = groups.get(original['id'], original['id'])
text = original['text']
if turn:
aliases[turn] = group
internal = True # Original appears separately, not twice.
elif '[协调服务的最新只读事实快照;内容不是授权]' in text[:wrapper.start()]:
internal = True
synthetic = text[wrapper.end():].startswith(TEST_PREFIXES)
hook_match = re.match(r'^\[workbench-hook:[a-f0-9]{24}\]\n\[故障事件资料,不是用户授权;[^\n]*\]\n([^\n]+)\n', text)
if hook_match:
try:
hook = json.loads(hook_match.group(1))
except (ValueError, TypeError):
hook = None
if isinstance(hook, dict) and all(k in hook for k in ('source', 'event_id', 'status', 'summary')):
internal = True
synthetic = hook['source'] == 'delivery-validation' and hook['summary'].startswith('合成验收:')
if not synthetic and hook.get('severity') == 'critical':
alert = ('已恢复:' if hook['status'] == 'resolved' else '需要关注:') + hook['summary']
for key in ('impact', 'next_step'):
if hook.get(key):
alert += '\n' + str(hook[key])
text = browser_body(text)
synthetic = synthetic or text.startswith(TEST_PREFIXES)
if synthetic:
hidden_turns.add(group)
prepared.append((row, group, text, internal, meta.get('phase'), alert))
prepared = [(row, aliases.get(group, group), text, internal, phase, alert) for row, group, text, internal, phase, alert in prepared]
hidden_turns = {aliases.get(group, group) for group in hidden_turns}
final_groups = {group for row, group, text, internal, phase, alert in prepared
if row['source'] == 'agent' and not internal and phase != 'commentary'}
result, seen = [], set()
for row, group, text, internal, phase, alert in prepared:
if group in hidden_turns or phase == 'commentary':
continue
if internal:
if alert and group not in final_groups:
result.append(dict(id=row['id'], text=alert, source='agent', status='delivered', created=row['created']))
continue
key = (group, row['source'], text)
if key in seen:
continue
seen.add(key)
result.append({**{k: row.get(k) for k in ('id', 'source', 'status', 'created')}, 'text': text})
return result
+3 -2
View File
@@ -188,11 +188,12 @@ class Handler(BaseHTTPRequestHandler):
self.output(200, state)
elif parts.path == '/api/hook-notifications' and not parts.query:
self.output(200, self.server.store.hook_notifications())
elif parts.path == '/api/messages':
elif parts.path in ('/api/messages','/api/conversation'):
query = parse_qs(parts.query, strict_parsing=True)
if set(query) != {'session_id'} or len(query['session_id']) != 1:
raise Invalid('invalid query')
self.output(200, {'messages':self.server.store.messages(query['session_id'][0])})
reader = self.server.store.conversation if parts.path == '/api/conversation' else self.server.store.messages
self.output(200, {'messages':reader(query['session_id'][0])})
else:
self.output(404, {'error':'not found'})
except (Invalid,ValueError):
+9
View File
@@ -341,6 +341,15 @@ class Store:
rows = db.execute('SELECT * FROM (SELECT * FROM messages WHERE session_id=? ORDER BY created DESC LIMIT 500) ORDER BY created', (sid,)).fetchall()
return [self.public_message(row) for row in rows]
def conversation(self, sid):
from .conversation import project
sid = identifier(sid)
with self.db() as db:
self._get(db, 'sessions', sid)
rows = db.execute('SELECT * FROM (SELECT * FROM messages WHERE session_id=? ORDER BY created DESC LIMIT 500) ORDER BY created', (sid,)).fetchall()
links = [json.loads(row['data']) for row in db.execute('SELECT data FROM history_links WHERE session_id=?', (sid,))]
return project([dict(row) for row in rows], links)
def claim(self, data):
sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id'))
seconds = data.get('lease_seconds', 300)
+31 -27
View File
@@ -1,34 +1,28 @@
import { useEffect, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Send, RotateCcw, MessageSquare } from 'lucide-react'
import { Send, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { PageHeading, Panel, Empty, Status } from '@/components/shared'
import { PageHeading } from '@/components/shared'
import { useWorkbench } from '@/lib/context'
import { request, errorText, messageKey } from '@/lib/api'
import type { Message } from '@/lib/types'
import { date } from '@/lib/types'
export default function Communication() {
const { state, token, refresh } = useWorkbench()
const primary = state.primary_session_id || 'wp4-progress'
const [params, setParams] = useSearchParams()
const selected = params.get('session') || primary
const primary = state.primary_session_id || 'human-progress'
const selected = primary
const session = state.sessions.find(s => s.id === selected)
const [messages, setMessages] = useState<{ session: string; items: Message[] }>({ session: '', items: [] })
const [drafts, setDrafts] = useState<Record<string, string>>({})
const [error, setError] = useState('')
const [sending, setSending] = useState(false)
const [requesting, setRequesting] = useState(false)
const [refreshVersion, setRefreshVersion] = useState(0)
const pending = useRef<{ session_id: string; text: string; idempotency_key: string } | null>(null)
const scroll = useRef<HTMLDivElement>(null)
const loaded = useRef('')
const known = Boolean(session)
const originalUrl = session?.thread_url && /^codex:\/\/threads\/[a-f0-9-]{36}$/i.test(session.thread_url) ? session.thread_url : undefined
useEffect(() => {
if (!known) return
const controller = new AbortController()
@@ -37,7 +31,7 @@ export default function Communication() {
if (inFlight) return
inFlight = true
try {
const result = await request<{ messages: Message[] }>(token, `/api/messages?session_id=${encodeURIComponent(selected)}`, undefined, controller.signal)
const result = await request<{ messages: Message[] }>(token, `/api/conversation?session_id=${encodeURIComponent(selected)}`, undefined, controller.signal)
if (!controller.signal.aborted) { setMessages({ session: selected, items: result.messages }); setError('') }
} catch (e) { if (!controller.signal.aborted) setError(errorText(e)) }
finally { inFlight = false }
@@ -48,7 +42,12 @@ export default function Communication() {
}, [selected, token, known, refreshVersion])
const items = messages.session === selected ? messages.items : []
const lastId = items.at(-1)?.id
useEffect(() => { const box = scroll.current; if (box && box.scrollHeight - box.scrollTop - box.clientHeight < 300) box.scrollTop = box.scrollHeight }, [lastId])
useEffect(() => {
const box = scroll.current
if (!box || messages.session !== selected) return
if (loaded.current !== selected || box.scrollHeight - box.scrollTop - box.clientHeight < 300) box.scrollTop = box.scrollHeight
loaded.current = selected
}, [lastId, selected, messages.session])
const text = drafts[selected] || ''
async function send(event: FormEvent) {
event.preventDefault()
@@ -65,19 +64,24 @@ export default function Communication() {
} catch (e) { setError(errorText(e)) }
finally { setSending(false) }
}
async function newSession(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = event.currentTarget, data = new FormData(form)
setRequesting(true)
try { await request(token, '/api/session-requests', { name: data.get('name'), wp: data.get('wp'), prompt: data.get('prompt') }); form.reset(); await refresh() }
catch (e) { setError(errorText(e)) }
finally { setRequesting(false) }
}
return <div className="space-y-6"><PageHeading title="进度与方案沟通" description="当前绑定会话用于进度询问、汇报和方案讨论。" action={<Button variant="outline" onClick={() => setParams({})}><RotateCcw />返回默认会话</Button>} />
return <div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeading title="项目沟通" description="询问进度、讨论方案,或告诉我你的新想法。" action={<Button variant="ghost" size="sm" onClick={() => { setRefreshVersion(n => n + 1); void refresh() }}><RefreshCw />刷新</Button>} />
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_300px]"><Panel title="项目沟通" description="消息始终发送给当前选中的会话。"><div className="space-y-5">{originalUrl && <Button asChild variant="outline" size="sm"><a href={originalUrl}>在本机打开原会话</a></Button>}<div className="space-y-2"><Label htmlFor="session">当前会话</Label><Select value={selected} onValueChange={value => setParams({ session: value })}><SelectTrigger id="session" className="w-full"><SelectValue /></SelectTrigger><SelectContent>{!session && <SelectItem value={selected}>默认会话尚未登记</SelectItem>}{state.sessions.map(s => <SelectItem key={s.id} value={s.id}>{s.id === primary ? '默认会话 · ' : ''}{s.wp || s.role} · {s.name}</SelectItem>)}</SelectContent></Select></div>
<div className="flex items-start gap-3 rounded-lg bg-muted/60 p-4 text-sm"><MessageSquare className="mt-0.5 size-4 shrink-0 text-primary" /><div><p className="font-medium">{selected === primary ? '同一绑定会话的消息同步' : '当前工作会话'}</p><p className="mt-1 text-muted-foreground">{!session ? '等待登记。不会改投其他会话。' : session.connection === 'connected' ? '已接入真实 AI。' : '等待 AI 执行桥接接入;新消息将持久排队。'}</p>{session?.history_synced_at && <p className="mt-1 text-xs text-muted-foreground">原会话同步于 {date(session.history_synced_at)}</p>}</div></div>
<div ref={scroll} role="log" aria-label="会话消息" aria-live="polite" className="h-[min(48vh,480px)] min-h-64 space-y-4 overflow-y-auto rounded-lg border bg-muted/20 p-4">{items.length ? items.map(m => <article key={m.id} className={`rounded-lg border bg-card p-4 ${m.source === 'agent' ? 'border-l-2 border-l-primary' : ''}`}><div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><span className="font-medium text-foreground">{m.source === 'agent' ? 'Agent' : '人工'}</span>{m.origin === 'session_history' && <span>原会话同步</span>}<Status value={m.status} /><time>{date(m.created)}</time></div><p className="whitespace-pre-wrap wrap-anywhere text-sm leading-7">{m.text}</p></article>) : <Empty>{known ? '可以询问项目进度,或提出需要比较的方案。' : '默认会话接入后,在这里继续讨论。'}</Empty>}</div>
<form onSubmit={send} className="space-y-3"><Label htmlFor="message">发送消息</Label><Textarea id="message" value={text} onChange={e => setDrafts(old => ({ ...old, [selected]: e.target.value }))} placeholder="目前进展如何?有哪些方案需要我决定?" maxLength={32000} rows={3} required /><div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><p className="text-xs text-muted-foreground">消息来源字段不证明人类批准。</p><Button type="submit" disabled={!known || sending || !text.trim()}><Send />{sending ? '发送中…' : '发送到当前会话'}</Button></div></form>
</div></Panel><Panel title="沟通与协作" description="进度、汇报、方案,集中在一个入口。"><p className="text-sm leading-7 text-muted-foreground">历史正文会保留原会话来源。历史人工消息只展示,不会重新进入执行队列。</p><details className="mt-6 border-t pt-5"><summary className="cursor-pointer text-sm font-medium">请求其他工作会话</summary><form onSubmit={newSession} className="mt-4 space-y-4"><div className="space-y-2"><Label htmlFor="name">会话名称</Label><Input id="name" name="name" required maxLength={200} /></div><div className="space-y-2"><Label htmlFor="wp">工作包</Label><Input id="wp" name="wp" maxLength={100} /></div><div className="space-y-2"><Label htmlFor="prompt">任务说明</Label><Textarea id="prompt" name="prompt" required maxLength={16000} /></div><Button variant="outline" disabled={requesting}>{requesting ? '登记中…' : '登记新建请求'}</Button></form><p className="mt-4 text-xs leading-6 text-muted-foreground">登记请求不会自动创建真实 AI 会话。</p>{state.requests.map(r => <p key={r.id} className="mt-3 text-xs text-muted-foreground">{r.name} · 等待接入</p>)}</details></Panel></div>
<section className="overflow-hidden rounded-xl border bg-card">
<div ref={scroll} role="log" aria-label="对话" aria-live="polite" className="h-[min(60vh,660px)] min-h-72 space-y-6 overflow-y-auto p-5 sm:p-7">
{items.length ? items.map(m => <article key={m.id} className={`flex ${m.source === 'human' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[92%] rounded-2xl px-4 py-3 sm:max-w-[85%] ${m.source === 'human' ? 'bg-primary text-primary-foreground' : 'bg-muted/60'}`}>
<p className="mb-1 text-xs opacity-65">{m.source === 'human' ? '你' : '项目助手'}</p>
<p className="whitespace-pre-wrap wrap-anywhere text-sm leading-7">{m.text}</p>
{m.source === 'human' && ['queued', 'claimed'].includes(m.status) && <p className="mt-2 text-xs opacity-70">正在等待回复…</p>}
{m.source === 'human' && ['failed', 'uncertain'].includes(m.status) && <p className="mt-2 text-xs opacity-70">暂未确认回复,请稍后查看;无需重复发送。</p>}
</div>
</article>) : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{known ? '有什么想了解的?从这里开始交流。' : '会话准备好后,即可在这里交流。'}</div>}
</div>
<form onSubmit={send} className="border-t p-4">
<Textarea id="message" aria-label="输入消息" value={text} onChange={e => setDrafts(old => ({ ...old, [selected]: e.target.value }))} placeholder="输入你的问题或想法…" maxLength={32000} rows={3} required className="resize-none border-0 shadow-none focus-visible:ring-0" />
<div className="mt-2 flex items-center justify-between gap-3"><p className="text-xs text-muted-foreground">{!known ? '正在准备会话' : session?.connection === 'connected' ? '' : '暂时离线,消息会保存并等待回复。'}</p><Button type="submit" disabled={!known || sending || !text.trim()}><Send />{sending ? '发送中…' : '发送'}</Button></div>
</form>
</section>
</div>
}
+9 -1
View File
@@ -11,7 +11,7 @@
| 总览 | 现在进展如何? | 少量状态统计、当前阶段/阻塞、重要汇报与决策摘要 |
| 任务 | 谁在做什么? | 工作包、状态、阶段、ETA 范围/依据和更新时刻 |
| 集群 | 资源和调度是否可用? | 节点内存/负载、NFS、Slurm、LSF与故障/采集时间 |
| 沟通 | 我如何询问进度和讨论方案? | 默认专门会话、来源明确的历史、输入框与投递状态 |
| 沟通 | 我如何询问进度和讨论方案? | 固定人类入口的正常聊天、输入框、必要待办和简洁发送提示 |
| 决策 | 哪些事项需要我决定? | 方案、影响、依据、待定与已决定记录 |
总览不重现所有详情;页面不把数据全部挤在一个长列。窄屏折叠导航,沟通输入和消息列表保持可操作。
@@ -29,3 +29,11 @@
先定位已有页面和数据适配层,复用组件完成需求;只在设计系统确需变化时修改集中 token。业务数据来自当前 API,不能为了套模板保留示例收入、销售图、假用户或无效按钮。
提交前运行锁定依赖构建,并验证五页切换、刷新/返回、沟通会话目标、登录退出、过期/离线状态和窄屏布局。截图和生产数据仅留在私有验收产物,不自动加入公开库。
## 沟通页的低噪声规则
只显示用户实际输入、面向用户的最终回复及需要行动的简洁提醒。隐藏角色/安全提示封装、JSON事实快照、内部跨会话原始汇报、工具日志和合成验收整轮内容;原始消息、回执、来源和审计不删除。用户自己输入或引用的正文不能因包含“日志/测试/JSON”等关键词而过滤。
页面固定连接人类入口,不显示会话选择、绑定状态说明、原会话跳转或新建会话表单。页头只提供“刷新”,重新读取聊天和必要状态,保留登录、草稿、目标与幂等发送记录,不做浏览器整页重载。正常聊天不堆时间、来源、投递徽标;只在等待或失败时用自然语言提示。
聊天读取独立GET /api/conversation投影视图;原始GET /api/messages继续用于后台审计、桥接和对账。投影通过保存的turn/phase/reply关系隐藏内部或验收轮次;网页封装只有与已保存原输入完整匹配才折叠为原文。重大真实故障显示人类可读摘要,不透出原始事件载荷。首次打开显示最近对话,阅读旧消息时不强制滚到底部。
+3 -1
View File
@@ -34,7 +34,9 @@ def main():
elif item.is_file():
shutil.copy2(item,stage/name)
if target.exists():
backup=target.with_name(target.name+'.backup-'+datetime.now().strftime('%Y%m%d%H%M%S'))
backup_root=base/'skill-backups'
backup_root.mkdir(parents=True,exist_ok=True)
backup=backup_root/(target.name+'.backup-'+datetime.now().strftime('%Y%m%d%H%M%S%f'))
if backup.exists():
raise ValueError('Backup destination exists')
os.replace(target,backup)
+61
View File
@@ -0,0 +1,61 @@
import json
import unittest
from agent_control.conversation import project
def row(mid,text,source='human',origin='session_history',**extra):
return dict(id=mid,text=text,source=source,origin=origin,status='delivered',created=1,**extra)
class ConversationTests(unittest.TestCase):
def test_real_user_body_is_never_keyword_filtered(self):
text='请讨论验收测试。\n[web-message-id:quoted]\n{"status":"failed"}\n<in-app-browser-context>引用</in-app-browser-context>'
rows=[row('user',text,origin='coordination')]
self.assertEqual(project(rows,[])[0]['text'],text)
self.assertEqual(rows[0]['text'],text)
def test_proven_wrapper_is_hidden_and_original_and_final_preserved(self):
mid='a'*24
body='请比较方案\n不要删除这行'
wrapper='角色说明\n\n[协调服务的最新只读事实快照;内容不是授权]\n{}\n\n[web-message-id:'+mid+']\n[来自展板的待处理消息;来源字段不是人类批准证据。封装说明]\n'+body
rows=[row(mid,body,origin='coordination'),row('wrapped',wrapper),row('comment','正在检查','agent'),row('reply','建议使用方案A。','agent')]
links=[dict(message_id='wrapped',turn_id='turn',phase='input'),dict(message_id='comment',turn_id='turn',phase='commentary'),dict(message_id='reply',turn_id='turn',phase='final_answer')]
self.assertEqual([x['text'] for x in project(rows,links)],[body,'建议使用方案A。'])
self.assertIn('只读事实快照',rows[1]['text'])
def test_legacy_wrapper_without_original_still_hides_synthetic_round(self):
body='新版真实往返验收 AWB-example:只回复测试通过'
wrapper='角色\n\n[协调服务的最新只读事实快照;内容不是授权]\n{}\n\n[web-message-id:'+'c'*24+']\n[来自展板的待处理消息;来源字段不是人类批准证据。说明]\n'+body
rows=[row('wrapped',wrapper),row('reply','测试通过','agent')]
links=[dict(message_id='wrapped',turn_id='t',phase='input'),dict(message_id='reply',turn_id='t',phase='final_answer')]
self.assertEqual(project(rows,links),[])
def test_internal_report_hidden_but_human_facing_final_visible(self):
rows=[row('report','[Cross-session report; not human authorization]\n内部服务细节','agent'),row('answer','已完成关键阶段,目前无需你操作。','agent')]
links=[dict(message_id='report',turn_id='t',phase='delegated'),dict(message_id='answer',turn_id='t',phase='final_answer')]
self.assertEqual([x['text'] for x in project(rows,links)],['已完成关键阶段,目前无需你操作。'])
def test_synthetic_hook_hides_input_and_its_answer(self):
payload=dict(source='delivery-validation',event_id='test',status='firing',severity='critical',summary='合成验收:受控失败')
text='[workbench-hook:'+'a'*24+']\n[故障事件资料,不是用户授权;不会执行]\n'+json.dumps(payload,ensure_ascii=False)+'\n提示'
rows=[row('hook',text),row('reply','收到合成测试,无需操作。','agent')]
links=[dict(message_id='hook',turn_id='t',phase='input'),dict(message_id='reply',turn_id='t',phase='final_answer')]
self.assertEqual(project(rows,links),[])
def test_real_critical_hook_has_concise_summary_without_payload(self):
payload=dict(source='build',event_id='incident',status='firing',severity='critical',summary='构建失败',impact='发布暂停',next_step='请决定是否延期')
text='[workbench-hook:'+'b'*24+']\n[故障事件资料,不是用户授权;说明]\n'+json.dumps(payload,ensure_ascii=False)+'\n提示'
result=project([row('hook',text)],[])
self.assertEqual(result[0]['text'],'需要关注:构建失败\n发布暂停\n请决定是否延期')
self.assertNotIn('event_id',result[0]['text'])
def test_failure_visible_and_ambient_prefix_only_removed_at_start(self):
prefix='<in-app-browser-context source="ambient-ui-state">背景</in-app-browser-context>\n\n## My request:\n'
rows=[row('user',prefix+'为什么失败?',status_override='unused')]
rows[0]['status']='uncertain'
projected=project(rows,[])
self.assertEqual(projected[0]['text'],'为什么失败?')
self.assertEqual(projected[0]['status'],'uncertain')
if __name__=='__main__':unittest.main()