refactor(schedule): keep reminder delivery conversational

This commit is contained in:
Tianyi Cui
2026-08-09 15:22:53 +08:00
parent 2f3e8974ec
commit 36ef892559
134 changed files with 598 additions and 3139 deletions

View File

@@ -24,5 +24,3 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
## Development
Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract.
Schedule reminders are opt-in rather than part of the default Web tree. `dsh web --patch examples/web-schedule/cordis.yml` loads the Schedule tools and receipt renderer over the existing JSONL persistence path; reminders run only while their original Session has a live root Agent and are reported as `session-local`, never as an external notification.

View File

@@ -19,7 +19,6 @@
"@cordisjs/plugin-timer": "workspace:*",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-base": "workspace:^",
"@deepseek-ai/dsh-client-ui-schedule": "workspace:^",
"@deepseek-ai/dsh-headless": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",

View File

@@ -87,7 +87,6 @@ describe('parseDshArgs', () => {
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
expect(exitCode(['web', '--patch='])).toBe(1)
expect(exitCode(['web', '--config', 'w.yml'])).toBe(1)
// Boot-free dumps derive no flag patches; silently dropping the flags
// would print a tree that differs from the same invocation's boot.
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)

View File

@@ -185,7 +185,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(help.stdout).toContain('dsh run "run the tests"')
expect(help.stdout).toContain('dsh plugin --profile')
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
for (const removed of [['tui'], ['--config', 'x.yml'], ['web', '--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
const result = await runBuiltBin(removed)
expect(result.code).toBe(1)
}

View File

@@ -167,11 +167,6 @@ export interface WebScaffold {
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/** Caller-owned workspace and persistence roots reused across process-style restarts. */
world?: {
workspaceCwd: string
persistenceRoot: string
}
/**
* Optional product overlay applied after the shipped Web surface and before
* the scaffold's hermetic test patches, matching the launcher's `--patch`
@@ -242,18 +237,11 @@ export interface LaunchOptions {
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
async function cleanupScaffoldWorld(
ctx: Context,
workspaceCwd: string,
persistenceRoot: string,
removeWorld: boolean,
): Promise<unknown[]> {
async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise<unknown[]> {
const failures: unknown[] = []
await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error))
if (removeWorld) {
await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
return failures
}
@@ -288,26 +276,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
process.env.DEEPSEEK_API_KEY = originalDeepSeekCredential
}
}
const ownsWorld = options.world === undefined
const workspaceCwd = options.world === undefined
? await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')))
: await realpath(options.world.workspaceCwd)
const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')))
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
// paths at load, and an in-process boot must NEVER touch the developer's
// real ~/.dsh document or credential file.
const harnessHome = join(workspaceCwd, '.dsh-home')
let persistenceRoot: string
if (options.world !== undefined) {
persistenceRoot = await realpath(options.world.persistenceRoot)
} else {
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
} catch (error) {
const failures: unknown[] = [error]
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
} catch (error) {
const failures: unknown[] = [error]
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
@@ -466,7 +447,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
} catch (error) {
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot, ownsWorld)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
restoreCredentialEnvironment()
if (cleanupFailures.length > 0) {
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
@@ -512,7 +493,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
failures.push(error)
}
try {
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot, ownsWorld))
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
} finally {
restoreCredentialEnvironment()
}

View File

@@ -1,77 +1,100 @@
// Keyless assembled-browser evidence for the opt-in Schedule overlay. A real
// root Agent receives schedule_create through the complete tool pipeline; the
// one-second owner path queues its best-effort followup, commits dispatch, and
// the browser renders the Host's durability-gated reminder sidecar. No model
// fixture is installed: the later prompt failure cannot retract the receipt.
import { mkdtemp, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
/** Keyless assembled-Web evidence for conversational Schedule delivery. */
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
import {
ScheduleId,
createAfterScheduleRecord,
foldScheduleEvents,
} from '@deepseek-ai/dsh-tool-schedule'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/schedule-after', import.meta.url))
const RECEIPT_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/receipt.expected.md', import.meta.url))
const CONVERSATION_EXPECTED = join(SNAPSHOT_DIR, 'conversation.expected.md')
const PROVIDER = 'schedule-web-test'
const MODEL = 'reply'
const PROMPT = 'Check the deployment log'
const REPLY = 'Reminder: Check the deployment log.'
interface CreatedScheduleView {
id: string
deliveryMode: 'session-local'
}
/** Wait for one in-process lifecycle fact without using test-scoped expect.poll in beforeAll. */
async function waitForFact(read: () => boolean, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!read()) {
if (Date.now() >= deadline) throw new Error(`Schedule lifecycle fact did not arrive within ${timeoutMs}ms`)
await new Promise(resolve => setTimeout(resolve, 20))
/** Deterministic model seam that turns the scheduled follow-up into ordinary assistant prose. */
class ReminderAdapter extends LlmAdapter {
override async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
/** Give a seeded Session one completed turn so the real Host fork path can cut it. */
function appendCompletedTurn(session: Session, prompt: string): void {
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: prompt }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
/** Extract text from one durable assistant message. */
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
return event.data.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', () => {
/** Wait for the exact scheduled assistant reply and return its durable sequence. */
async function waitForReply(handle: AgentHandle, timeoutMs: number): Promise<number> {
const deadline = Date.now() + timeoutMs
while (true) {
const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => (
candidate.type === 'assistant/message' && assistantText(candidate) === REPLY
))
if (event !== undefined) return event.seq
if (Date.now() >= deadline) throw new Error(`scheduled assistant reply did not arrive within ${timeoutMs}ms`)
await new Promise<void>(resolve => setTimeout(resolve, 20))
}
}
describe.skipIf(MODE === 'record')('web e2e: conversational after reminder', () => {
let scaffold: WebScaffold
let agentHandle: AgentHandle
let browser: Browser
let page: Page
let scheduleId = ''
let assistantSeq = -1
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([PROVIDER], new ReminderAdapter()),
'schedule Web reminder adapter',
)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const cwd = join(scaffold.workspaceCwd, 'workspace')
agentHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('schedule-after-web-e2e'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
meta: { cwd },
agentOptions: { provider: PROVIDER, model: MODEL },
})
const workspace = await scaffold.ctx.workspace.create(scaffold.workspaceCwd, 'Schedule')
agentHandle.agent.session.append('session/title', {
title: 'Scheduled follow-up',
messageSeqs: [],
source: { kind: 'user' },
})
const workspace = await scaffold.ctx.workspace.resolveByPath(cwd)
if (workspace === undefined) throw new Error('connected Web workspace was not registered')
await workspace.attachSession(agentHandle.agent.id)
const created = await scaffold.ctx.tools.execute({
@@ -82,47 +105,23 @@ describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', ()
agent: agentHandle.agent,
})
expect(created.isError).toBe(false)
if (created.isError) throw new Error(created.error.message)
const value = created.value as unknown as CreatedScheduleView
expect(value.deliveryMode).toBe('session-local')
scheduleId = value.id
expect(scheduleId.length).toBeGreaterThan(0)
await waitForFact(() => agentHandle.agent.session.events.some(event =>
event.type === 'schedule/change'
&& (event.data as { operation?: unknown }).operation === 'dispatch'), 15_000)
assistantSeq = await waitForReply(agentHandle, 15_000)
await agentHandle.agent.whenIdle()
await expect(scaffold.ctx.sessions.flush(agentHandle.agent.session)).resolves.toBe(true)
const durable = await scaffold.ctx.sessionPersistence.inspect(agentHandle.agent.id)
expect(durable.meta).toMatchObject(agentHandle.agent.session.header)
expect({ ...durable.meta, delegationDepth: durable.meta.delegationDepth ?? 0 }).toEqual({
...agentHandle.agent.session.header,
delegationDepth: agentHandle.agent.session.header.delegationDepth ?? 0,
})
expect(durable.events).toEqual(agentHandle.agent.session.events.slice(0, durable.events.length))
const stored = await scaffold.ctx.sessionPersistence.inspect(agentHandle.agent.id)
expect(stored.events.filter(event => (
event.type === 'schedule/change' && event.data.operation === 'dispatch'
))).toHaveLength(1)
const history = await scaffold.ctx.apiProxy.sessions.history({
rpcId: RpcId('schedule-history-baseline'), payload: { sessionId: agentHandle.agent.id },
rpcId: RpcId('schedule-after-history'),
payload: { sessionId: agentHandle.agent.id },
})
if (!history.result.ok) throw new Error(history.result.error.message)
expect(history.result.value.events?.find(entry =>
entry.event.type === 'schedule/change'
&& (entry.event.data as { operation?: unknown }).operation === 'dispatch')?.view).toMatchObject({
for: 'event',
})
await waitForFact(
() => agentHandle.agent.session.events.some(event => event.type === 'turn/start'),
10_000,
)
const listed = await scaffold.ctx.apiProxy.sessions.list({
rpcId: RpcId('schedule-list-baseline'), payload: {},
})
if (!listed.result.ok) throw new Error(listed.result.error.message)
expect(listed.result.value.items.find(item => item.sessionId === agentHandle.agent.id)?.blank).toBe(false)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const dispatch = history.result.value.events.find(entry => (
entry.event.type === 'schedule/change' && entry.event.data.operation === 'dispatch'
))
expect(dispatch?.view).toBeUndefined()
}, 120_000)
afterAll(async () => {
@@ -134,152 +133,28 @@ describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', ()
if (failures.length > 1) throw new AggregateError(failures, 'Schedule Web evidence teardown failed')
})
it('renders the committed reminder from attached history', async () => {
it('renders the reminder as an ordinary assistant follow-up', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-after'))
const group = page.locator('[role="treeitem"]').first()
await group.waitFor({ timeout: 15_000 })
if (await group.getAttribute('aria-expanded') !== 'true') {
await group.click()
}
await expect.poll(() => group.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
const session = page.locator('[role="treeitem"][aria-selected]').nth(1)
await session.waitFor({ timeout: 10_000 })
const session = page.getByRole('treeitem', { name: /Scheduled follow-up/ })
await session.waitFor({ timeout: 15_000 })
await session.click()
const receipt = page.locator('[data-schedule-reminder]')
await receipt.waitFor({ timeout: 15_000 })
expect(await receipt.getByText(PROMPT, { exact: true }).count()).toBe(1)
expect(await receipt.getByText('Delivered in this session only', { exact: true }).count()).toBe(1)
const snapshot = (await captureStableAria(page, '[data-schedule-reminder]', scaffold.workspaceCwd))
.split(scheduleId).join('{{scheduleId}}')
.replace(/\d{4}-\d{2}-\d{2}T(?:\d{2}:\d{2}:\d{2}\.\d{3}|\{\{clock\}\})Z/gu, '{{occurrenceAt}}')
await compareOrRefreshGolden(RECEIPT_EXPECTED, snapshot, MODE)
const selector = `[data-chat-anchor-key="node:${String(assistantSeq)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await row.textContent()).toContain(REPLY)
await compareOrRefreshGolden(
CONVERSATION_EXPECTED,
await captureStableAria(page, selector, scaffold.workspaceCwd),
MODE,
)
expect(await page.locator('[data-schedule-reminder]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['receipt.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['conversation.expected.md'])
})
})
describe.skipIf(MODE === 'record')('web e2e: Schedule restart, fork, and cold history', () => {
it('preserves pending work, commits one overdue receipt, and replays it cold without activation', async () => {
const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-schedule-restart-ws-')))
const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-schedule-restart-sessions-'))
const world = { workspaceCwd, persistenceRoot }
const pendingId = SessionId('schedule-restart-pending')
const deliveredId = SessionId('schedule-restart-delivered')
let scaffold: WebScaffold | undefined
try {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world })
const workspace = await scaffold.ctx.workspace.create(workspaceCwd, 'Schedule restart')
const pending = scaffold.ctx.sessions.create(pendingId, { meta: { cwd: workspaceCwd } })
appendCompletedTurn(pending, 'pending parent turn')
pending.append('session/title', {
title: 'Pending restart session', messageSeqs: [], source: { kind: 'user' },
})
const pendingRecord = createAfterScheduleRecord(
ScheduleId('schedule-pending'), 'Pending across restart', 3_600, Date.now(),
)
pending.append('schedule/change', { version: 1, operation: 'create', schedule: pendingRecord })
await expect(scaffold.ctx.sessions.flush(pending)).resolves.toBe(true)
await workspace.attachSession(pendingId)
const delivered = scaffold.ctx.sessions.create(deliveredId, { meta: { cwd: workspaceCwd } })
appendCompletedTurn(delivered, 'delivered parent turn')
delivered.append('session/title', {
title: 'Delivered restart session', messageSeqs: [], source: { kind: 'user' },
})
const overdueRecord = createAfterScheduleRecord(
ScheduleId('schedule-delivered'), 'Delivered after restart', 1, Date.now() - 60_000,
)
delivered.append('schedule/change', { version: 1, operation: 'create', schedule: overdueRecord })
await expect(scaffold.ctx.sessions.flush(delivered)).resolves.toBe(true)
await workspace.attachSession(deliveredId)
await scaffold.close()
scaffold = undefined
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world })
const pendingResume = await scaffold.ctx.apiProxy.sessions.create({
rpcId: RpcId('schedule-pending-resume'),
payload: { sessionId: pendingId, cwd: workspaceCwd },
})
if (!pendingResume.result.ok) throw new Error(pendingResume.result.error.message)
const pendingAgent = scaffold.ctx.agents.get(pendingId)
if (pendingAgent === undefined) throw new Error('pending Session did not resume')
expect(foldScheduleEvents(
pendingAgent.session.events,
pendingAgent.session.header.seedLength ?? 0,
).active).toEqual([expect.objectContaining({ id: 'schedule-pending' })])
const forked = await scaffold.ctx.apiProxy.sessions.fork({
rpcId: RpcId('schedule-pending-fork'),
payload: { sessionId: pendingId },
})
if (!forked.result.ok) throw new Error(forked.result.error.message)
const child = scaffold.ctx.agents.get(forked.result.value.sessionId)
if (child === undefined) throw new Error('fork child was not published')
expect(foldScheduleEvents(
child.session.events,
child.session.header.seedLength ?? 0,
).active).toEqual([])
const deliveredResume = await scaffold.ctx.apiProxy.sessions.create({
rpcId: RpcId('schedule-delivered-resume'),
payload: { sessionId: deliveredId, cwd: workspaceCwd },
})
if (!deliveredResume.result.ok) throw new Error(deliveredResume.result.error.message)
const deliveredAgent = scaffold.ctx.agents.get(deliveredId)
if (deliveredAgent === undefined) throw new Error('overdue Session did not resume')
await waitForFact(() => deliveredAgent.session.events.some(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch'), 15_000)
await deliveredAgent.whenIdle()
await expect(scaffold.ctx.sessions.flush(deliveredAgent.session)).resolves.toBe(true)
expect(deliveredAgent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1)
await scaffold.close()
scaffold = undefined
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world })
expect(scaffold.ctx.agents.get(deliveredId)).toBeUndefined()
const coldHistory = await scaffold.ctx.apiProxy.sessions.history({
rpcId: RpcId('schedule-cold-history'),
payload: { sessionId: deliveredId },
})
if (!coldHistory.result.ok) throw new Error(coldHistory.result.error.message)
const dispatchEntries = coldHistory.result.value.events.filter(entry =>
entry.event.type === 'schedule/change'
&& entry.event.data.operation === 'dispatch')
expect(dispatchEntries).toHaveLength(1)
expect(dispatchEntries[0]?.view?.for).toBe('event')
expect(scaffold.ctx.agents.get(deliveredId)).toBeUndefined()
await scaffold.close()
scaffold = undefined
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world })
const replayed = await scaffold.ctx.apiProxy.sessions.create({
rpcId: RpcId('schedule-delivered-replay'),
payload: { sessionId: deliveredId, cwd: workspaceCwd },
})
if (!replayed.result.ok) throw new Error(replayed.result.error.message)
const replayedAgent = scaffold.ctx.agents.get(deliveredId)
if (replayedAgent === undefined) throw new Error('delivered Session did not resume again')
await replayedAgent.whenIdle()
await expect(scaffold.ctx.sessions.flush(replayedAgent.session)).resolves.toBe(true)
expect(replayedAgent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1)
} finally {
const failures: unknown[] = []
await scaffold?.close().catch((error: unknown) => failures.push(error))
await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'Schedule restart evidence teardown failed')
}
}, 180_000)
})

View File

@@ -0,0 +1,6 @@
- paragraph: "Reminder: Check the deployment log."
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -1,6 +0,0 @@
- note:
- banner: Scheduled reminder Delivered in this session only
- paragraph: Check the deployment log
- contentinfo:
- text: ID {{scheduleId}}
- time: Due at {{occurrenceAt}}