Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
46a2393
fix(runtime): 重试退避加 ±20% jitter,去相关化并发 agent 重试风暴
yishuiliunian Jul 18, 2026
5b169d7
feat(runtime): 流 idle 超时,卡死的响应流转为可恢复截断而非挂到 300s
yishuiliunian Jul 18, 2026
52c5e71
feat(tools): 编辑失败给可执行诊断(行尾/空白差异 + 最近匹配行 + 重读提示)
yishuiliunian Jul 18, 2026
5650061
refactor(memory): 规范化 body_preview→body 存全文,FTS 索引完整正文
yishuiliunian Jul 18, 2026
d60beec
feat(tools): Write 加 staleness 守卫,拒绝覆盖读取后被改动的文件
yishuiliunian Jul 18, 2026
9879260
test(runtime): 补编辑提示 + Write staleness 的 agent-turn 行为测试
yishuiliunian Jul 18, 2026
8909341
test(e2e): 新增 CLI headless × mock-llm 全栈行为测试 harness
yishuiliunian Jul 18, 2026
c1ecdd9
test(e2e): 加跨进程取消场景(agent/interrupt 取消在途 turn)
yishuiliunian Jul 18, 2026
d5c38fd
test(e2e): 扩展 CLI 全栈覆盖——多 provider + thinking 流 + 并行工具
yishuiliunian Jul 18, 2026
81d0477
test(e2e): 多轮会话连续性 + compaction 走真实 wire
yishuiliunian Jul 18, 2026
c6b24a6
test(e2e): MCP 工具走真实 turn——harness 扮演 Hub 应答 mcp 代理
yishuiliunian Jul 18, 2026
4addae3
test(e2e): secret 走真实 tool call + Hub 降级/恢复走 wire
yishuiliunian Jul 18, 2026
1de823d
test(e2e): 权限流(allow/deny/classifier) + 会话跨进程恢复
yishuiliunian Jul 18, 2026
7141874
test(e2e): 控制命令组/排队消息/loop守卫 + 修 warnings_injected 恒零
yishuiliunian Jul 18, 2026
35578d7
test(e2e): memory_recall 全文命中 + settings hooks 环绕工具执行
yishuiliunian Jul 18, 2026
8fac424
test(e2e): 系统提示注入上 wire 断言 + 运行时沙箱切换阻写
yishuiliunian Jul 18, 2026
0ec1935
test(e2e): hub_llm 全拓扑家族——真 Hub spawn 真 MCP 子进程走完整 turn
yishuiliunian Jul 18, 2026
18247c0
fix(e2e): hub_llm 注册后先排空启动事件积压,根治 turn 边界误判
yishuiliunian Jul 18, 2026
b5c3aa4
test(e2e): hub_llm 家族补齐——真子代理 turn/observer/真 age vault 链
yishuiliunian Jul 18, 2026
e4e487e
test(e2e): 文件系统工具链走 wire——Write/Read/Edit + 预检 + overflow
yishuiliunian Jul 18, 2026
e62d3ce
fix(backend): 孤儿清理加 48h 静默期守卫,不再误删他 HOME 活跃会话目录
yishuiliunian Jul 18, 2026
a8fb4ec
test(e2e): auto-compaction/工具执行中断/loop abort 档
yishuiliunian Jul 18, 2026
b9ba389
test(e2e): AskUser 问答流 + Plan 审批流走真实 wire
yishuiliunian Jul 18, 2026
4d75ebc
ci: Agent E2E 入 PR 门禁——专属 job + manual 标签出三平台矩阵
yishuiliunian Jul 18, 2026
7307f62
fix(desktop): 根治 CI 两处 flaky——settings 未加载可保存 + 树查询竞态
yishuiliunian Jul 18, 2026
391fb49
fix(desktop-e2e): 重试横幅断言适配 jitter——精确延迟改模式匹配
yishuiliunian Jul 18, 2026
0738405
fix(desktop): 再除两处"等 mock 调用→立即断 DOM"竞态
yishuiliunian Jul 18, 2026
c406dd5
fix(config): Windows 锁竞态两处 + env 测试 HOME 密闭化;child-agent 超时放宽
yishuiliunian Jul 18, 2026
542ec2d
fix(desktop): child-agent 测试根治——面板 toggle 竞态收敛式选择
yishuiliunian Jul 18, 2026
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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ jobs:
- name: Rustfmt check
run: bazel build //... --config=rustfmt

agent-e2e:
name: Agent E2E
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Setup Bazel
uses: bazel-contrib/setup-bazel@0.14.0
with:
bazelisk-cache: true
repository-cache: true

- name: Agent behavior E2E (serve + hub topology, mock LLM wire)
run: >-
bazel test
//:cli_llm_e2e_test
//:hub_llm_e2e_test
--test_output=errors

desktop-e2e:
name: Desktop E2E
runs-on: macos-latest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ test('OpenAI retries a transport closed before response headers', async () => {
const conversation = page.getByTestId('conversation')
await ready(page)
await send(page, 'Recover OpenAI before headers')
await expect(conversation).toContainText('Retrying in 2.0s', { timeout: 10_000 })
await expect(conversation).toContainText(/Retrying in \d+\.\ds/, { timeout: 10_000 })
await expect(conversation).toContainText(
'OpenAI recovered after its pre-header transport failure.', { timeout: 20_000 },
)
await expect(conversation).not.toContainText('Retrying in 2.0s')
await expect(conversation).not.toContainText('Retrying in')
expect((await desktop.llm!.requests()).map((request) => request.protocol))
.toEqual(['openai_responses', 'openai_responses'])
await expect.poll(() => desktop.llm!.state()).toMatchObject({
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/e2e/real/provider/host-provider-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ test('renders retry, fatal, recovery, and retry-exhaustion HTTP states', async (
await ready(page)

await send(page, 'Recover one server error')
await expect(conversation).toContainText('Retrying in 2.0s', { timeout: 10_000 })
await expect(conversation).toContainText(/Retrying in \d+\.\ds/, { timeout: 10_000 })
await expect(conversation).toContainText('Recovered after one HTTP 503.', {
timeout: 20_000,
})
await expect(conversation).not.toContainText('Retrying in 2.0s')
await expect(conversation).not.toContainText('Retrying in')
await ready(page)

await send(page, 'Exercise fatal authentication')
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/e2e/real/provider/host-provider-runtime.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ test('retries a real Anthropic HTTP 429 and clears the retry state', async () =>
await ready(page)
await send(page, 'Recover from a scripted rate limit')
await expect(page.getByTestId('conversation')).toContainText(
'Retrying in 2.0s', { timeout: 10_000 },
/Retrying in \d+\.\ds/, { timeout: 10_000 },
)
await expect(page.getByTestId('conversation')).toContainText(
'Recovered through the real provider retry loop.', { timeout: 15_000 },
)
await expect(page.getByTestId('conversation')).not.toContainText('Retrying in 2.0s')
await expect(page.getByTestId('conversation')).not.toContainText('Retrying in')
expect(await desktop.llm!.requests()).toHaveLength(2)
await expect.poll(() => desktop.llm!.state()).toMatchObject({
served: 2, remaining: 0, verified: true,
Expand Down Expand Up @@ -84,7 +84,7 @@ test('retries a connection closed before HTTP headers', async () => {
const conversation = desktop.page.getByTestId('conversation')
await ready(desktop.page)
await send(desktop.page, 'Recover from a pre-header disconnect')
await expect(conversation).toContainText('Retrying in 2.0s', { timeout: 10_000 })
await expect(conversation).toContainText(/Retrying in \d+\.\ds/, { timeout: 10_000 })
await expect(conversation).toContainText(
'Recovered after the transport disappeared before headers.', { timeout: 15_000 },
)
Expand All @@ -103,13 +103,13 @@ test('interrupts a retry wait and accepts the next turn immediately', async () =
const conversation = page.getByTestId('conversation')
await ready(page)
await send(page, 'Wait on a long provider retry')
await expect(conversation).toContainText('Retrying in 30.0s', { timeout: 10_000 })
await expect(conversation).toContainText(/Retrying in \d{2}\.\ds/, { timeout: 10_000 })
await page.getByRole('button', { name: 'Settings' }).click()
await selectSettingsSection(page, 'agent')
await page.getByRole('group', { name: 'Agent controls' })
.getByRole('button', { name: 'Interrupt' }).click()
await page.getByRole('button', { name: 'Close settings' }).click()
await expect(conversation).not.toContainText('Retrying in 30.0s', { timeout: 10_000 })
await expect(conversation).not.toContainText('Retrying in', { timeout: 10_000 })
await expect(page.getByLabel('Message Loopal')).toBeEnabled({ timeout: 10_000 })
await send(page, 'Recover after interrupting retry wait')
await expect(conversation).toContainText(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ describe('Workbench session catalog', () => {

fireEvent.click(list.getByText('Write the guide'))
await waitFor(() => expect(openSession).toHaveBeenCalledWith(docsSession.id))
expect(screen.getByTestId('active-session-title')).toHaveTextContent('Write the guide')
// The header re-renders after the openSession promise resolves — waiting
// on the mock call alone races that commit.
await waitFor(() => expect(screen.getByTestId('active-session-title'))
.toHaveTextContent('Write the guide'))
expect(screen.getByText('Conversation for Write the guide')).toBeInTheDocument()
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { richView } from '../../../../../test/fixtures/workbench/rich-session'
import {
createTestAPI, sessionDetail, sessionOne, updatedAt,
} from '../../../../../test/support/workbench/api-stub'
import { Workbench } from '../../../browser/workbench'

// Clicking an already-selected panel tab COLLAPSES the panel (toggle
// semantics), so a blind click races the zone's initial expansion state and
// can hide the tree it meant to open. Converge instead: each retry expands
// the panel if it is collapsed, then selects the agent node.
async function selectAgent(root: HTMLElement, name: RegExp): Promise<void> {
await waitFor(() => {
const tab = within(root).getByRole('tab', { name: 'Agents' })
if (tab.getAttribute('aria-expanded') !== 'true') fireEvent.click(tab)
fireEvent.click(within(root).getByRole('treeitem', { name }))
}, { timeout: 15_000 })
}

describe('Workbench child agent selection', () => {
it('routes messages and resources to a live child while retaining completed output', async () => {
const detail = {
Expand All @@ -26,14 +38,15 @@ describe('Workbench child agent selection', () => {
}
const sendMessage = vi.fn(async () => undefined)
const { api, events } = createTestAPI({ openSession: async () => detail, sendMessage })
render(<Workbench api={api} />)
const { container } = render(<Workbench api={api} />)
await screen.findByText(`Conversation for ${sessionOne.title}`)
fireEvent.change(screen.getByLabelText('Message Loopal'), {
target: { value: 'Root-only draft' },
})
fireEvent.click(screen.getByRole('tab', { name: 'Agents' }))
fireEvent.click(screen.getByRole('treeitem', { name: /Research child/ }))
expect(screen.getByText('Child context compacted.')).toBeInTheDocument()
await selectAgent(container, /Research child/)
expect(
await screen.findByText('Child context compacted.', {}, { timeout: 15_000 }),
).toBeInTheDocument()
expect(screen.getByText('Child-only goal')).toBeInTheDocument()
const composer = screen.getByLabelText('Message Research child')
expect(composer).toHaveValue('')
Expand Down Expand Up @@ -66,13 +79,12 @@ describe('Workbench child agent selection', () => {
}],
}
const { api } = createTestAPI({ openSession: async () => detail })
render(<Workbench api={api} />)
const { container } = render(<Workbench api={api} />)
await screen.findByText(`Conversation for ${sessionOne.title}`)
fireEvent.click(screen.getByRole('tab', { name: 'Agents' }))
fireEvent.click(screen.getByRole('treeitem', { name: /Starting child/ }))
expect(screen.getByTestId('conversation')).toHaveTextContent(
await selectAgent(container, /Starting child/)
await waitFor(() => expect(within(container).getByTestId('conversation')).toHaveTextContent(
'Viewing Starting child · starting · waiting for registration',
)
), { timeout: 15_000 })
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ describe('LoopalDefaultSettings', () => {
expect(restartButton).toBeEnabled()
fireEvent.click(restartButton)
await waitFor(() => expect(restart).toHaveBeenCalledWith('session'))
expect(screen.getByRole('status')).toHaveTextContent('restarted with the saved')
// The status line updates only after the restart promise resolves and
// commits — waiting on the mock call alone races that commit.
await waitFor(() => expect(screen.getByRole('status'))
.toHaveTextContent('restarted with the saved'))
})

it('surfaces load, validation, save, and restart failures without leaking credentials', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ describe('MetaHubSettingsSection', () => {
})
const view = render(<MetaHubSettingsSection api={api} target={target} />)
const panel = await screen.findByTestId('metahub-settings')
// The form is disabled until the persisted settings replace the
// placeholder defaults; the loaded hub name marks that moment.
await within(panel).findByDisplayValue('desktop-ui')
fireEvent.change(within(panel).getByLabelText('MetaHub address'), {
target: { value: 'meta.internal:9000' },
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export function MetaHubSettingsSection(props: {
}): React.JSX.Element | null {
const { t } = useI18n()
const [settings, setSettings] = useState(emptySettings)
// reason: until the persisted settings arrive, `settings` holds placeholder
// defaults — editing or saving in that window would overwrite the user's
// stored values (and join under the placeholder hub name), so the form and
// every settings-writing action stay disabled while !loaded.
const [loaded, setLoaded] = useState(false)
const [token, setToken] = useState('')
const [runtime, setRuntime] = useState(props.initialState)
const [local, setLocal] = useState<LocalMetaHubStatus>({ state: 'stopped' })
Expand All @@ -34,7 +39,7 @@ export function MetaHubSettingsSection(props: {
let active = true
const acceptError = (value: unknown): void => { if (active) setError(message(value)) }
void props.api.getMetaHubSettings().then((next) => {
if (active) setSettings(next)
if (active) { setSettings(next); setLoaded(true) }
}).catch(acceptError)
void props.api.getLocalMetaHubStatus().then((next) => {
if (active) setLocal(next)
Expand Down Expand Up @@ -106,39 +111,42 @@ export function MetaHubSettingsSection(props: {
<div className="metahub-fields">
<Field label={t('settings.metahub.address')}>
<input aria-label={t('settings.metahub.addressAria')} value={settings.address}
disabled={!loaded}
onChange={(event) => setSettings({ ...settings, address: event.target.value })} />
</Field>
<Field label={t('settings.metahub.hubName')}>
<input aria-label={t('settings.metahub.hubNameAria')} value={settings.hubName}
disabled={!loaded}
onChange={(event) => setSettings({ ...settings, hubName: event.target.value })} />
</Field>
<Field label={t('settings.metahub.token')}>
<input aria-label={t('settings.metahub.tokenAria')} type="password" value={token}
autoComplete="off" placeholder={t(settings.tokenConfigured
autoComplete="off" disabled={!loaded} placeholder={t(settings.tokenConfigured
? 'settings.metahub.tokenConfigured'
: 'settings.metahub.tokenRequired')}
onChange={(event) => setToken(event.target.value)} />
</Field>
</div>
<label className="settings-check"><input aria-label={t('settings.metahub.joinOnStartAria')}
type="checkbox" checked={settings.joinOnStart}
type="checkbox" checked={settings.joinOnStart} disabled={!loaded}
onChange={(event) => setSettings({ ...settings, joinOnStart: event.target.checked })} />
<span>{t('settings.metahub.joinOnStart')}</span></label>
<label className="settings-check"><input aria-label={t('settings.metahub.startLocalAria')}
type="checkbox" checked={settings.startLocalOnLaunch}
type="checkbox" checked={settings.startLocalOnLaunch} disabled={!loaded}
onChange={(event) => setSettings({ ...settings, startLocalOnLaunch: event.target.checked })} />
<span>{t('settings.metahub.startLocal')}</span></label>
<div className="metahub-actions">
<button disabled={busy} onClick={() => void execute(async () => { await save() })}>
<button disabled={busy || !loaded} onClick={() => void execute(async () => { await save() })}>
{t('common.save')}
</button>
<button disabled={busy || !settings.tokenConfigured} onClick={() => void execute(async () => {
const next = UpdateMetaHubSettingsInputSchema.parse({
...settings, clearToken: true,
})
setSettings(await props.api.updateMetaHubSettings(next)); setToken('')
})}>{t('settings.metahub.clearToken')}</button>
<button disabled={busy || !props.target} onClick={join}>
<button disabled={busy || !loaded || !settings.tokenConfigured}
onClick={() => void execute(async () => {
const next = UpdateMetaHubSettingsInputSchema.parse({
...settings, clearToken: true,
})
setSettings(await props.api.updateMetaHubSettings(next)); setToken('')
})}>{t('settings.metahub.clearToken')}</button>
<button disabled={busy || !loaded || !props.target} onClick={join}>
{t('settings.metahub.join')}
</button>
<button disabled={busy || !props.target || runtime?.state === 'disconnected'}
Expand Down
65 changes: 64 additions & 1 deletion build_defs/rust/root_tests.bzl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Rust tests owned by the root Bazel package."""

load("@rules_rust//rust:defs.bzl", "rust_test")
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test")
load("//build_defs/rust:desktop_test.bzl", "desktop_serve_test")

def _binary_e2e(name, src, deps):
Expand Down Expand Up @@ -63,6 +63,69 @@ def loopal_root_tests():
"@crates//:tokio",
],
)
rust_test(
name = "cli_llm_e2e_test",
srcs = native.glob(["tests/e2e/cli_llm/*.rs"]),
crate_root = "tests/e2e/cli_llm/suite.rs",
data = [":loopal"],
edition = "2024",
env = {"LOOPAL_BINARY": "$(rootpath :loopal)"},
local = True,
# reason: excluded from `//...` wildcards (three-OS CI matrix) and run
# by the dedicated Agent E2E gate job, mirroring the desktop e2e setup.
tags = [
"e2e",
"manual",
],
deps = [
"//crates/loopal-ipc",
"//crates/loopal-mock-llm:loopal-mock-llm-lib",
"//crates/loopal-protocol",
"@crates//:chrono",
"@crates//:reqwest",
"@crates//:serde_json",
"@crates//:tempfile",
"@crates//:tokio",
"@crates//:uuid",
],
)
rust_binary(
name = "mock_mcp_server",
srcs = ["tests/fixtures/mock_mcp_server/main.rs"],
edition = "2024",
deps = ["@crates//:serde_json"],
)
rust_test(
name = "hub_llm_e2e_test",
srcs = native.glob(["tests/e2e/hub_llm/*.rs"]),
crate_root = "tests/e2e/hub_llm/suite.rs",
data = [
":loopal",
":mock_mcp_server",
],
edition = "2024",
env = {
"LOOPAL_BINARY": "$(rootpath :loopal)",
"LOOPAL_MOCK_MCP_BINARY": "$(rootpath :mock_mcp_server)",
},
local = True,
tags = [
"e2e",
"manual",
],
deps = [
"//crates/loopal-ipc",
"//crates/loopal-mock-llm:loopal-mock-llm-lib",
"//crates/loopal-protocol",
"//crates/loopal-vault-age",
"//crates/loopal-vault-api",
"@crates//:reqwest",
"@crates//:secrecy",
"@crates//:serde_json",
"@crates//:tempfile",
"@crates//:tokio",
],
)
desktop_serve_test()
rust_test(
name = "loopal-unit-test",
Expand Down
4 changes: 2 additions & 2 deletions crates/loopal-backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ pub use log_writer::{
};
pub use process_group::{KillOutcome, SpawnedChild, kill_process_group};
pub use tmp_cleanup::{
cleanup_orphans, cleanup_orphans_in, cleanup_session_tmp, cleanup_session_tmp_in,
loopal_tmp_root, session_tmp_root, session_tmp_root_in,
ORPHAN_MIN_AGE, cleanup_orphans, cleanup_orphans_in, cleanup_session_tmp,
cleanup_session_tmp_in, loopal_tmp_root, session_tmp_root, session_tmp_root_in,
};
Loading
Loading