mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into feat/ripgrep-packaged-binary
This commit is contained in:
@@ -72,6 +72,7 @@ describe('core Web profile', () => {
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
"list_agents",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -67,6 +67,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The frame mounts before the asynchronous session-list baseline lands.
|
||||
// Search must target the settled seeded row, not the startup input that
|
||||
// the ready projection replaces.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.m
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
|
||||
const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
|
||||
const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
|
||||
const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -172,10 +173,95 @@ describe('web e2e: queue row actions', () => {
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
|
||||
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-'))
|
||||
const readyFile = join(overrideDir, '.hang-ready')
|
||||
const overridePath = join(overrideDir, 'replay.override.json')
|
||||
await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry]))
|
||||
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill('/goal Keep the composer context panels aligned')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
|
||||
await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 })
|
||||
|
||||
const sessions = scaffold.ctx.sessions.list()
|
||||
expect(sessions).toHaveLength(1)
|
||||
sessions[0]!.append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'Confirm the panel order', status: 'completed' },
|
||||
{ content: 'Align the panel widths', status: 'in_progress' },
|
||||
],
|
||||
})
|
||||
await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 })
|
||||
|
||||
for (const text of ['Layout queue first', 'Layout queue second']) {
|
||||
await input.fill(text)
|
||||
await input.press('Enter')
|
||||
}
|
||||
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
|
||||
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
|
||||
.toBe('false')
|
||||
|
||||
const layoutSnapshot = await captureStableAria(
|
||||
page,
|
||||
'[class*="centerCol"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
|
||||
|
||||
const expectAlignedContextPanels = async () => {
|
||||
const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
|
||||
const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
|
||||
const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
|
||||
expect(queuePanelBox).not.toBeNull()
|
||||
expect(todoBox).not.toBeNull()
|
||||
expect(goalBox).not.toBeNull()
|
||||
expect(todoBox!.y).toBeLessThan(goalBox!.y)
|
||||
expect(goalBox!.y).toBeLessThan(queuePanelBox!.y)
|
||||
expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1)
|
||||
expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1)
|
||||
expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1)
|
||||
expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1)
|
||||
}
|
||||
await expectAlignedContextPanels()
|
||||
await page.setViewportSize({ width: 640, height: 1000 })
|
||||
await expectAlignedContextPanels()
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
|
||||
await queueHeader.click()
|
||||
const removeButtons = page.getByRole('button', { name: 'Remove queued message' })
|
||||
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2)
|
||||
await removeButtons.first().click()
|
||||
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1)
|
||||
await removeButtons.first().click()
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Clear goal' }).click()
|
||||
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await settled
|
||||
|
||||
expect(turnEndReasons(sessionEvents)).toEqual(['aborted'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
['collapsed.expected.md', 'editing.expected.md', 'preserved.expected.md', 'ui.expected.md'],
|
||||
['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,6 +121,11 @@ export interface LaunchOptions {
|
||||
* mounts).
|
||||
*/
|
||||
replayFixture?: string
|
||||
/**
|
||||
* Recorded child logs assigned in child creation order. Each child owns its
|
||||
* own positional replay cursor across initial and continuation turns.
|
||||
*/
|
||||
replayChildFixtures?: string[]
|
||||
/**
|
||||
* Optional replay.override.json sidecar (whole-script replacement or
|
||||
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
|
||||
@@ -328,6 +333,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
file: options.replayFixture,
|
||||
providers: REPLAY_PROVIDERS,
|
||||
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ const EXPECTED_TOOLS = [
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
'send_message',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- 'heading "Using ONE run_code program: run" [level=1]'
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Using ONE run_code program: run" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use only Cordis tools. First" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use only Cordis tools. First" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the bash tool to" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the bash tool to" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with the single word" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with the single word" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the read tool twice" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the read tool twice" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- 'heading "Plan a small change: add" [level=1]'
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Plan a small change: add" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the ask_user_question tool to" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
43
apps/web/tests/snapshots/queue-actions/layout.expected.md
Normal file
43
apps/web/tests/snapshots/queue-actions/layout.expected.md
Normal file
@@ -0,0 +1,43 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
|
||||
- img
|
||||
- img
|
||||
- text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- region "To-dos":
|
||||
- button "To-dos 1/2 tasks · 1 in progress"
|
||||
- img
|
||||
- text: Ongoing Goal Keep the composer context panels aligned
|
||||
- button "Pause goal":
|
||||
- img
|
||||
- button "Edit goal":
|
||||
- img
|
||||
- button "Clear goal":
|
||||
- img
|
||||
- button "2 queued messages"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the read tool twice" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the read tool twice" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the read tool twice" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the read tool twice" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the ask_user_question tool to" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the ask_user_question tool to" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 2 sessions" [expanded]:
|
||||
- img
|
||||
- text: workspace 2 sessions
|
||||
- treeitem "Explain event sourcing in one (1) now" [selected]
|
||||
- treeitem "Ask a research subagent to now"
|
||||
@@ -0,0 +1,18 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher"
|
||||
- text: /
|
||||
- button "example editor" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Give one concrete event sourcing example. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- status:
|
||||
- strong: 此子代理暂时只读
|
||||
- text: 父会话当前不在线,重新打开父会话后即可继续发送消息。
|
||||
@@ -0,0 +1,5 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "Ask a research subagent to now"
|
||||
@@ -0,0 +1,8 @@
|
||||
- tree "子代理会话":
|
||||
- treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚" [expanded] [level=1]:
|
||||
- button "收起 event-sourcing researcher 的下级子代理":
|
||||
- img
|
||||
- text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚
|
||||
- group:
|
||||
- treeitem "example editor 可继续 · 当前未运行 刚刚" [level=2]
|
||||
- treeitem "event-sourcing reviewer 一次性 · 当前未运行 刚刚" [level=1]
|
||||
@@ -0,0 +1,50 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- button "1 个子代理":
|
||||
- text: 1 个子代理
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Explain event sourcing in one sentence. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Now give the same explanation to a human reader. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 2 steps Context 6% of 128K Cache hit 99% Input 15.6K tok · Output 158 tok
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use web_search to search exactly" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use web_search to search exactly" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
432
apps/web/tests/subagent-conversation.e2e.ts
Normal file
432
apps/web/tests/subagent-conversation.e2e.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
|
||||
const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
|
||||
const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
|
||||
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
|
||||
const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const LABEL = 'event-sourcing researcher'
|
||||
const ONE_SHOT_LABEL = 'event-sourcing reviewer'
|
||||
const NESTED_LABEL = 'example editor'
|
||||
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
|
||||
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
|
||||
|
||||
function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
|
||||
const [header, ...eventLines] = source.trimEnd().split('\n')
|
||||
if (header === undefined) throw new Error('base replay fixture has no header')
|
||||
const childHeader = header
|
||||
.replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
|
||||
.replace(/"createdAt":\d+/, '"createdAt":1784998084442')
|
||||
if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
|
||||
const continued = eventLines.map(line => line
|
||||
.replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
|
||||
.replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
|
||||
.replaceAll('"turn":1', '"turn":2'))
|
||||
return [childHeader, ...eventLines, ...continued, ''].join('\n')
|
||||
}
|
||||
|
||||
async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promise<void> {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (scaffold.ctx.agents.get(id) !== undefined) {
|
||||
if (Date.now() >= deadline) throw new Error(`subagent ${id} did not settle`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: persisted subagent conversation and human continuation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let sidecarRoot: string
|
||||
let childId: SessionId
|
||||
let oneShotId: SessionId
|
||||
let grandchildId: SessionId
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const apiCalls: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
|
||||
const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
|
||||
const childFixturePath = join(sidecarRoot, 'child.jsonl')
|
||||
await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: BASE_FIXTURE,
|
||||
replayChildFixtures: [childFixturePath],
|
||||
paceMs: 25,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const parent = scaffold.ctx.agents.roots()[0]
|
||||
if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
|
||||
const parentSettled = scaffold.whenTurnSettled()
|
||||
const parentInput = page.locator('textarea:enabled').first()
|
||||
await parentInput.fill(PARENT_PROMPT)
|
||||
await parentInput.press('Enter')
|
||||
expect(await parentSettled).toBe(parent.id)
|
||||
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: LABEL,
|
||||
signal: new AbortController().signal,
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
|
||||
parent,
|
||||
},
|
||||
})
|
||||
childId = started.childId
|
||||
await waitForAgentToSettle(scaffold, childId)
|
||||
oneShotId = sessionId('recorded-one-shot')
|
||||
const oneShotAt = Date.now()
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: oneShotId,
|
||||
createdAt: oneShotAt,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
delegationDepth: 1,
|
||||
})
|
||||
await scaffold.ctx.sessionPersistence.append(oneShotId, [
|
||||
{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: oneShotAt,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: oneShotAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Review the event sourcing explanation.' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: oneShotAt + 2,
|
||||
data: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: oneShotAt + 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
grandchildId = sessionId('recorded-grandchild')
|
||||
const authoredAt = Date.now()
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: grandchildId,
|
||||
createdAt: authoredAt,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
parentSession: childId,
|
||||
origin: 'subagent',
|
||||
delegationDepth: 2,
|
||||
})
|
||||
await scaffold.ctx.sessionPersistence.append(grandchildId, [
|
||||
{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: authoredAt,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: authoredAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: authoredAt + 2,
|
||||
data: snapshotSubagentDescriptor({
|
||||
mode: 'continuable', provider: 'spawn', label: NESTED_LABEL,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: authoredAt + 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
|
||||
{
|
||||
kind: 'child', id: childId, mode: 'continuable', label: LABEL,
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: oneShotId, mode: 'one-shot',
|
||||
label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
|
||||
{
|
||||
kind: 'child', id: grandchildId, mode: 'continuable',
|
||||
label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
// These two cold fixtures were authored after the page's initial
|
||||
// session.list and intentionally emitted no session-added frame. Reload
|
||||
// to exercise the restart baseline that discovers their full lineage.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const catalogButton = page.getByRole('button', { name: /个子代理/ })
|
||||
await catalogButton.waitFor({ timeout: 15_000 })
|
||||
await catalogButton.click()
|
||||
const catalogTree = page.getByRole('tree', { name: '子代理会话' })
|
||||
await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
|
||||
await catalogTree.press('Escape')
|
||||
await page.getByRole('button', { name: '3 个子代理' }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarRoot !== undefined) {
|
||||
await rm(sidecarRoot, { 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, 'subagent Web teardown failed')
|
||||
})
|
||||
|
||||
it('expands a persisted grandchild progressively without activating either level', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
expect(await page.getByRole('button', {
|
||||
name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`,
|
||||
}).count()).toBe(0)
|
||||
await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
const snapshot = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="子代理会话"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('tree', { name: '子代理会话' }).press('Escape')
|
||||
})
|
||||
|
||||
it('opens the completed child from persistence without activating it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(
|
||||
() => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(1)
|
||||
if (scaffold.ctx.agents.get(childId) !== undefined) {
|
||||
throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
|
||||
}
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button', { name: LABEL, disabled: true }).waitFor()
|
||||
const sidebar = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="Sessions"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||
})
|
||||
|
||||
it('continues through FIFO follow-up admission and receives the child mux events', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
|
||||
const ended = new Promise<void>((resolveEnded, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error('subagent follow-up did not reach turn/end'))
|
||||
}, 30_000)
|
||||
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
if (session.id !== childId || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
resolveEnded()
|
||||
})
|
||||
})
|
||||
const input = page.getByRole('textbox', { name: 'Message the agent' })
|
||||
await input.fill(FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.get(childId)?.status,
|
||||
{ timeout: 10_000 },
|
||||
).toBe('running')
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button').first().click()
|
||||
const runningTrigger = page.getByRole('button', { name: '3 个子代理,正在运行' })
|
||||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await runningTrigger.click()
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*正在运行`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
await ended
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*当前未运行`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByRole('button', { name: '3 个子代理' })
|
||||
.locator('[data-state="ongoing"]').count()).toBe(0)
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
|
||||
})
|
||||
|
||||
it('matches the settled addressed-conversation aria golden and stays clean', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('opens an unavailable persisted grandchild after recording the available child', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
|
||||
await page.getByRole('button', { name: '1 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click()
|
||||
await page.getByText('父会话当前不在线,重新打开父会话后即可继续发送消息。').waitFor()
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
const crumbs = await hierarchy.getByRole('button').allTextContents()
|
||||
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
await compareOrRefreshGolden(
|
||||
UNAVAILABLE_GRANDCHILD_EXPECTED,
|
||||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a one-shot child as permanently read-only history', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot'))
|
||||
const parentSession = page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByRole('treeitem')
|
||||
.last()
|
||||
await parentSession.click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
|
||||
await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('places an ordinary fork from a subagent beside its workspace-owning ancestor', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-fork'))
|
||||
await page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByRole('treeitem', { name: /Ask a research subagent to/ })
|
||||
.click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
|
||||
const forkResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/session.fork')
|
||||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||||
const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } }
|
||||
expect(forkReceipt.result).toMatchObject({ ok: true })
|
||||
await expect.poll(
|
||||
() => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(3)
|
||||
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
expect(await hierarchy.getByRole('button').count()).toBe(1)
|
||||
await compareOrRefreshGolden(
|
||||
FORK_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
})
|
||||
|
||||
it('cold-resumes the original subagent while its ordinary fork stays active', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
|
||||
const sessions = page.getByRole('tree', { name: 'Sessions' })
|
||||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await page.locator('textarea:enabled').first().waitFor()
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
|
||||
const forkResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/session.fork')
|
||||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||||
const forkReceipt = await (await forkResponse).json() as {
|
||||
result: { ok: true; value: { sessionId: string } } | { ok: false }
|
||||
}
|
||||
expect(forkReceipt.result).toMatchObject({ ok: true })
|
||||
if (!forkReceipt.result.ok) return
|
||||
const forkId = sessionId(forkReceipt.result.value.sessionId)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||||
|
||||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.waitFor()
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
await input.fill(POST_FORK_FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
const promptReceipt = await (await promptResponse).json() as {
|
||||
result: { ok: true } | { ok: false; error: { code: string; message: string } }
|
||||
}
|
||||
if (!promptReceipt.result.ok) {
|
||||
throw new Error(`post-fork follow-up rejected: ${JSON.stringify(promptReceipt.result.error)}`)
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
const messageIndex = loaded.events.findIndex(event => event.type === 'user/message'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text === POST_FORK_FOLLOWUP))
|
||||
return messageIndex >= 0 && loaded.events.slice(messageIndex + 1).some(event => event.type === 'turn/end')
|
||||
}, { timeout: 30_000 }).toBe(true)
|
||||
expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -52,7 +52,8 @@
|
||||
"tests/access-confirmation.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts"
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user