Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b9e03c2c1 | ||
|
|
b34eb8f24b | ||
|
|
625cfad888 | ||
|
|
3859367e3a | ||
|
|
92c0974e19 | ||
|
|
f7d859afac | ||
|
|
b522f7df40 | ||
|
|
1787145f2a | ||
|
|
f581dfc016 | ||
|
|
26291a0f42 | ||
|
|
80b9f5e083 | ||
|
|
bbebeec3dc | ||
|
|
6a69ec2a8a | ||
|
|
f874a6a019 | ||
|
|
bcdd52a27b | ||
|
|
32e9de8ae3 | ||
|
|
35c9e3e3d3 | ||
|
|
d37783c895 | ||
|
|
cceeabcb46 | ||
|
|
8536c232ba | ||
|
|
8383571c53 | ||
|
|
953b42ac67 | ||
|
|
4e069c1f23 | ||
|
|
09b5d63c39 | ||
|
|
8f2cd4e5b0 | ||
|
|
b57e5da995 | ||
|
|
daf13834a6 | ||
|
|
309777f8fc | ||
|
|
184670f49d | ||
|
|
63aa8af5c1 | ||
|
|
ee30e59871 | ||
|
|
0199589c3b |
@@ -96,6 +96,10 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
|
||||
|
||||
def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]:
|
||||
return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json")
|
||||
|
||||
|
||||
class AgentComposerService:
|
||||
@classmethod
|
||||
def load_workflow_composer(
|
||||
@@ -419,7 +423,11 @@ class AgentComposerService:
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
agent.active_config_is_published = False
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
|
||||
@@ -430,6 +438,54 @@ class AgentComposerService:
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _agent_soul_matches_active_config(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> bool:
|
||||
if not agent.active_config_snapshot_id:
|
||||
return False
|
||||
|
||||
active_version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
if not active_version:
|
||||
return False
|
||||
if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
):
|
||||
return False
|
||||
|
||||
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
|
||||
|
||||
@classmethod
|
||||
def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool:
|
||||
revisions = db.session.scalars(
|
||||
select(AgentConfigRevision.operation).where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
AgentConfigRevision.current_snapshot_id == snapshot_id,
|
||||
)
|
||||
).all()
|
||||
|
||||
return any(
|
||||
operation
|
||||
in {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
}
|
||||
for operation in revisions
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def publish_agent_app_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
|
||||
@@ -557,16 +613,21 @@ class AgentComposerService:
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
|
||||
normal_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict),
|
||||
agent_soul=applied_agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
base_snapshot_id=build_draft.base_snapshot_id,
|
||||
)
|
||||
agent.active_config_is_published = False
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=applied_agent_soul,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
|
||||
@@ -992,9 +992,10 @@ class AgentRosterService:
|
||||
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
|
||||
"""Return each Agent's stored normal-draft publish state.
|
||||
|
||||
The flag is maintained by write paths: normal shared draft writes mark it
|
||||
dirty, while publish/version creation paths mark it clean. User-scoped
|
||||
debug drafts intentionally do not affect this state.
|
||||
The flag is maintained by write paths against the normal shared draft:
|
||||
saves compare the draft content with the active snapshot, while publish
|
||||
and version creation paths mark the new active snapshot clean.
|
||||
User-scoped debug drafts intentionally do not affect this state.
|
||||
"""
|
||||
agents = [agent for agent in agents if agent.id]
|
||||
if not agents:
|
||||
|
||||
@@ -437,10 +437,12 @@ def test_save_agent_app_composer_rejects_version_save_strategy():
|
||||
def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
updated_by=None,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent])
|
||||
saved = {}
|
||||
|
||||
@@ -451,6 +453,7 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
|
||||
"_save_agent_draft",
|
||||
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
@@ -473,6 +476,43 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = _agent_soul_with_model()
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=False,
|
||||
updated_by=None,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=agent_soul.model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent], scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_save_agent_draft",
|
||||
lambda **_kwargs: SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"agent_soul": agent_soul.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
|
||||
)
|
||||
|
||||
assert agent.active_config_is_published is True
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
@@ -565,7 +605,11 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
|
||||
assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict
|
||||
assert fake_session.commits == 1
|
||||
|
||||
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft])
|
||||
active_version = SimpleNamespace(config_snapshot_dict=build_draft.config_snapshot_dict)
|
||||
fake_session = FakeSession(
|
||||
scalar=[agent, build_draft, normal_draft, active_version],
|
||||
scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]],
|
||||
)
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
|
||||
applied = AgentComposerService.apply_agent_app_build_draft(
|
||||
@@ -576,6 +620,62 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
|
||||
|
||||
assert applied["result"] == "success"
|
||||
assert applied["draft"]["id"] == normal_draft.id
|
||||
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
|
||||
assert agent.active_config_is_published is True
|
||||
assert fake_session.deleted == [build_draft]
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_agent_app_build_draft_apply_marks_unpublished_when_build_draft_differs(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Iris",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
active_agent_soul = _agent_soul_with_model()
|
||||
build_agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
**active_agent_soul.model_dump(mode="json"),
|
||||
"prompt": {
|
||||
"system_prompt": "Build draft prompt",
|
||||
},
|
||||
}
|
||||
)
|
||||
build_draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id="account-1",
|
||||
draft_owner_key="account-1",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=build_agent_soul,
|
||||
)
|
||||
normal_draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=active_agent_soul,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=active_agent_soul.model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft, active_version])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
|
||||
AgentComposerService.apply_agent_app_build_draft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
|
||||
assert agent.active_config_is_published is False
|
||||
assert fake_session.deleted == [build_draft]
|
||||
@@ -1611,6 +1711,52 @@ def test_composer_create_roster_agent_raises_when_backing_agent_missing(monkeypa
|
||||
)
|
||||
|
||||
|
||||
def test_agent_app_draft_match_does_not_mark_create_version_as_published(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = AgentSoulConfig()
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
|
||||
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.CREATE_VERSION]])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
|
||||
|
||||
assert (
|
||||
AgentComposerService._agent_soul_matches_active_config(
|
||||
tenant_id="tenant-1",
|
||||
agent=agent,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_agent_app_draft_match_marks_publish_visible_revision_as_published(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = AgentSoulConfig()
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
|
||||
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
|
||||
|
||||
assert (
|
||||
AgentComposerService._agent_soul_matches_active_config(
|
||||
tenant_id="tenant-1",
|
||||
agent=agent,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch):
|
||||
fake_session = FakeSession(
|
||||
scalar=[
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChatConfig, ChatItemInTree } from '../../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { sseGet, ssePost } from '@/service/base'
|
||||
import { useChat } from '../hooks'
|
||||
@@ -353,6 +353,74 @@ describe('useChat', () => {
|
||||
expect(result.current.chatList[1]!.id).toBe('m-1')
|
||||
})
|
||||
|
||||
it('should process inputs with the per-send input form override', () => {
|
||||
vi.mocked(ssePost).mockImplementation(async () => undefined)
|
||||
const { result } = renderHook(() => useChat(undefined, {
|
||||
inputs: {},
|
||||
inputsForm: [{
|
||||
type: InputVarType.textInput,
|
||||
label: 'City',
|
||||
variable: 'city',
|
||||
required: true,
|
||||
hide: false,
|
||||
}],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', {
|
||||
query: 'hello',
|
||||
inputs: {
|
||||
enabled: undefined,
|
||||
},
|
||||
overrideInputsForm: [{
|
||||
type: InputVarType.checkbox,
|
||||
label: 'Enabled',
|
||||
variable: 'enabled',
|
||||
required: true,
|
||||
hide: false,
|
||||
}],
|
||||
}, {})
|
||||
})
|
||||
|
||||
expect(ssePost).toHaveBeenCalledWith(
|
||||
'test-url',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
inputs: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should settle a send once when the SSE stream errors', () => {
|
||||
let callbacks: HookCallbacks
|
||||
const onSendSettled = vi.fn()
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'hello' }, {
|
||||
onSendSettled,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onError()
|
||||
callbacks.onCompleted(true)
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
expect(onSendSettled).toHaveBeenCalledTimes(1)
|
||||
expect(onSendSettled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle onThought and different workflow events', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
@@ -1106,6 +1174,44 @@ describe('useChat', () => {
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
|
||||
it('should settle a resume once when the event stream errors', () => {
|
||||
let callbacks: HookCallbacks
|
||||
const onSendSettled = vi.fn()
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const prevChatTree = [{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [{
|
||||
id: 'm-resume-error',
|
||||
content: 'initial',
|
||||
isAnswer: true,
|
||||
siblingIndex: 0,
|
||||
}],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[]))
|
||||
|
||||
act(() => {
|
||||
result.current.handleResume('m-resume-error', 'wr-error', {
|
||||
isPublicAPI: true,
|
||||
onSendSettled,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onError()
|
||||
callbacks.onCompleted(true)
|
||||
})
|
||||
|
||||
expect(onSendSettled).toHaveBeenCalledTimes(1)
|
||||
expect(onSendSettled).toHaveBeenCalledWith(true)
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
|
||||
it('should abort previous workflow event stream when resuming again', () => {
|
||||
const callbacksList: HookCallbacks[] = []
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
@@ -1489,6 +1595,45 @@ describe('useChat', () => {
|
||||
|
||||
expect(clearChatListCallback).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should keep the first send after a reset acknowledgement', () => {
|
||||
let clearChatList = true
|
||||
const clearChatListCallback = vi.fn((nextClearChatList: boolean) => {
|
||||
clearChatList = nextClearChatList
|
||||
})
|
||||
const { rerender, result } = renderHook(() => useChat(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
clearChatList,
|
||||
clearChatListCallback,
|
||||
))
|
||||
|
||||
expect(clearChatListCallback).toHaveBeenCalledWith(false)
|
||||
|
||||
rerender()
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'first after reset' }, {})
|
||||
})
|
||||
|
||||
expect(ssePost).toHaveBeenCalledWith(
|
||||
'test-url',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
query: 'first after reset',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(result.current.chatList).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: 'first after reset',
|
||||
isAnswer: false,
|
||||
}),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotations and siblings', () => {
|
||||
|
||||
@@ -361,6 +361,32 @@ describe('ChatInputArea', () => {
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should keep the textarea when async send is rejected by the owner', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn().mockResolvedValue(false)
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()!
|
||||
|
||||
await user.type(textarea, 'Keep this message')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled())
|
||||
expect(textarea).toHaveValue('Keep this message')
|
||||
})
|
||||
|
||||
it('should keep the textarea when async send fails', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn().mockRejectedValue(new Error('send failed'))
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()!
|
||||
|
||||
await user.type(textarea, 'Retry this message')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled())
|
||||
expect(textarea).toHaveValue('Retry this message')
|
||||
})
|
||||
|
||||
it('should call onSend and reset the input when pressing Enter', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn()
|
||||
|
||||
@@ -24,6 +24,8 @@ type AudioRecorderWithPermission = typeof Recorder & {
|
||||
getPermission: () => Promise<void>
|
||||
}
|
||||
|
||||
type SendAcceptance = void | boolean | Promise<void | boolean>
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
readonly?: boolean
|
||||
botName?: string
|
||||
@@ -62,10 +64,19 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
const historyRef = useRef([''])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const isComposingRef = useRef(false)
|
||||
const queryRef = useRef('')
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
queryRef.current = value
|
||||
setQuery(value)
|
||||
setTimeout(handleTextareaResize, 0)
|
||||
}, [handleTextareaResize])
|
||||
const resetAcceptedMessage = useCallback((acceptedQuery: string, acceptedFiles: ReturnType<typeof filesStore.getState>['files']) => {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (queryRef.current === acceptedQuery)
|
||||
handleQueryChange('')
|
||||
if (files === acceptedFiles)
|
||||
setFiles([])
|
||||
}, [filesStore, handleQueryChange])
|
||||
const handleSend = () => {
|
||||
if (!canSend)
|
||||
return
|
||||
@@ -75,15 +86,23 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
return
|
||||
}
|
||||
if (onSend) {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
const { files } = filesStore.getState()
|
||||
if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
if (checkInputsForm(inputs, inputsForm)) {
|
||||
onSend(query, files)
|
||||
handleQueryChange('')
|
||||
setFiles([])
|
||||
const sendResult = onSend(query, files) as SendAcceptance
|
||||
if (sendResult instanceof Promise) {
|
||||
sendResult.then((accepted) => {
|
||||
if (accepted !== false)
|
||||
resetAcceptedMessage(query, files)
|
||||
}).catch(noop)
|
||||
return
|
||||
}
|
||||
|
||||
if (sendResult !== false)
|
||||
resetAcceptedMessage(query, files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type SendCallback = {
|
||||
onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
|
||||
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onSendSettled?: (hasError?: boolean) => void
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
@@ -256,10 +257,19 @@ export const useChat = (
|
||||
{
|
||||
onGetSuggestedQuestions,
|
||||
onConversationComplete,
|
||||
onSendSettled,
|
||||
isPublicAPI,
|
||||
}: SendCallback,
|
||||
) => {
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
let hasSettled = false
|
||||
const settleSend = (hasError?: boolean) => {
|
||||
if (hasSettled)
|
||||
return
|
||||
|
||||
hasSettled = true
|
||||
onSendSettled?.(hasError)
|
||||
}
|
||||
// Re-subscribe to workflow events for the specific message
|
||||
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
|
||||
|
||||
@@ -303,25 +313,29 @@ export const useChat = (
|
||||
async onCompleted(hasError?: boolean) {
|
||||
handleResponding(false)
|
||||
|
||||
if (hasError)
|
||||
return
|
||||
try {
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
messageId,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setSuggestedQuestions([])
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
messageId,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
catch {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
settleSend(hasError)
|
||||
}
|
||||
},
|
||||
onFile(file) {
|
||||
// Convert simple file type to MIME type for non-agent mode
|
||||
@@ -404,6 +418,7 @@ export const useChat = (
|
||||
},
|
||||
onError() {
|
||||
handleResponding(false)
|
||||
settleSend(true)
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
handleResponding(true)
|
||||
@@ -669,6 +684,7 @@ export const useChat = (
|
||||
onGetConversationMessages,
|
||||
onGetSuggestedQuestions,
|
||||
onConversationComplete,
|
||||
onSendSettled,
|
||||
isPublicAPI,
|
||||
}: SendCallback,
|
||||
) => {
|
||||
@@ -721,13 +737,14 @@ export const useChat = (
|
||||
handleResponding(true)
|
||||
hasStopRespondedRef.current = false
|
||||
|
||||
const { query, files, inputs, ...restData } = data
|
||||
const { query, files, inputs, overrideInputsForm, ...restData } = data
|
||||
const requestInputsForm = overrideInputsForm ?? formSettings?.inputsForm ?? []
|
||||
const bodyParams = {
|
||||
response_mode: 'streaming',
|
||||
conversation_id: conversationIdRef.current,
|
||||
files: getProcessedFiles(files || []),
|
||||
query,
|
||||
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
|
||||
inputs: getProcessedInputs(inputs || {}, requestInputsForm),
|
||||
...restData,
|
||||
}
|
||||
if (bodyParams?.files?.length) {
|
||||
@@ -744,6 +761,14 @@ export const useChat = (
|
||||
|
||||
let isAgentMode = false
|
||||
let hasSetResponseId = false
|
||||
let hasSettled = false
|
||||
const settleSend = (hasError?: boolean) => {
|
||||
if (hasSettled)
|
||||
return
|
||||
|
||||
hasSettled = true
|
||||
onSendSettled?.(hasError)
|
||||
}
|
||||
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
|
||||
@@ -802,63 +827,67 @@ export const useChat = (
|
||||
async onCompleted(hasError?: boolean) {
|
||||
handleResponding(false)
|
||||
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
|
||||
const { data }: any = await onGetConversationMessages(
|
||||
conversationIdRef.current,
|
||||
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
|
||||
if (!newResponseItem)
|
||||
try {
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
|
||||
updateChatTreeNode(responseItem.id, {
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message.at(-1).role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
text: newResponseItem.answer,
|
||||
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
more: {
|
||||
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
|
||||
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
|
||||
latency: newResponseItem.provider_response_latency.toFixed(2),
|
||||
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
|
||||
},
|
||||
// for agent log
|
||||
conversationId: conversationIdRef.current,
|
||||
input: {
|
||||
inputs: newResponseItem.inputs,
|
||||
query: newResponseItem.query,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
responseItem.id,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
|
||||
const { data }: any = await onGetConversationMessages(
|
||||
conversationIdRef.current,
|
||||
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
|
||||
if (!newResponseItem)
|
||||
return
|
||||
|
||||
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
|
||||
updateChatTreeNode(responseItem.id, {
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message.at(-1).role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
text: newResponseItem.answer,
|
||||
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
more: {
|
||||
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
|
||||
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
|
||||
latency: newResponseItem.provider_response_latency.toFixed(2),
|
||||
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
|
||||
},
|
||||
// for agent log
|
||||
conversationId: conversationIdRef.current,
|
||||
input: {
|
||||
inputs: newResponseItem.inputs,
|
||||
query: newResponseItem.query,
|
||||
},
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setSuggestedQuestions([])
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
responseItem.id,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
catch {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
settleSend(hasError)
|
||||
}
|
||||
},
|
||||
onFile(file) {
|
||||
// Convert simple file type to MIME type for non-agent mode
|
||||
@@ -965,6 +994,7 @@ export const useChat = (
|
||||
},
|
||||
onError() {
|
||||
handleResponding(false)
|
||||
settleSend(true)
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { getDefaultStore } from 'jotai'
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
|
||||
@@ -282,6 +289,10 @@ describe('useCreateInlineAgentBinding', () => {
|
||||
describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const store = getDefaultStore()
|
||||
store.set(agentComposerOriginalConfigAtom, undefined)
|
||||
store.set(agentComposerOriginalDraftAtom, defaultAgentSoulConfigFormState)
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
it('saves inline agent composer changes through the workflow node composer API', async () => {
|
||||
@@ -317,6 +328,13 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
getDefaultStore().set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Workflow inline prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
@@ -332,7 +350,7 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
agent_soul: expect.objectContaining({
|
||||
schema_version: 1,
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: '',
|
||||
system_prompt: 'Workflow inline prompt',
|
||||
}),
|
||||
model: expect.objectContaining({
|
||||
model_provider: 'langgenius/openai/openai',
|
||||
@@ -378,6 +396,13 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
getDefaultStore().set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Manual inline prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
@@ -392,8 +417,97 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
save_strategy: 'node_job_only',
|
||||
agent_soul: expect.objectContaining({
|
||||
schema_version: 1,
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Manual inline prompt',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}, expect.any(Object))
|
||||
})
|
||||
|
||||
it('does not save manually when the inline agent composer draft is unchanged', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({
|
||||
nodeId: 'node-1',
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
},
|
||||
enabled: true,
|
||||
}), {
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(mockComposerMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toBeUndefined()
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('saves the effective inline model when the form draft is unchanged', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({
|
||||
nodeId: 'node-1',
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
},
|
||||
currentModel: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
enabled: true,
|
||||
}), {
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(mockComposerMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
model_provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
plugin_id: 'langgenius/openai',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}), expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentSoulConfig, WorkflowAgentComposerResponse } from '@dify/contr
|
||||
import type { DefaultModelResponse } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { useStore as useJotaiStore, useSetAtom } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
@@ -144,9 +145,18 @@ export function useWorkflowInlineAgentConfigureSync({
|
||||
if (!enabledRef.current)
|
||||
return
|
||||
|
||||
const configSnapshot = getAgentSoulDraft()
|
||||
const hasEffectiveModelChange = !isEqual(configSnapshot.model, baseConfigRef.current?.model)
|
||||
debouncedSaveDraft.cancel?.()
|
||||
return saveComposer(getAgentSoulDraft())
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer])
|
||||
if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange)
|
||||
return
|
||||
|
||||
return saveComposer(configSnapshot)
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
|
||||
const saveAgentSoulConfig = useCallback(async (agentSoulConfig: AgentSoulConfig) => {
|
||||
debouncedSaveDraft.cancel?.()
|
||||
return saveComposer(agentSoulConfig)
|
||||
}, [debouncedSaveDraft, saveComposer])
|
||||
|
||||
useEffect(() => {
|
||||
return store.sub(agentComposerDraftAtom, () => {
|
||||
@@ -175,6 +185,7 @@ export function useWorkflowInlineAgentConfigureSync({
|
||||
|
||||
return {
|
||||
draftSavedAt,
|
||||
saveAgentSoulConfig,
|
||||
saveDraft,
|
||||
}
|
||||
}
|
||||
|
||||
+536
-41
@@ -1,11 +1,17 @@
|
||||
import type { AgentSoulConfig, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { WorkflowInlineAgentConfigureWorkspace } from '../agent-orchestrate-panel-content'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkoutBuildDraft: vi.fn(),
|
||||
deleteBuildDraft: vi.fn(),
|
||||
loadBuildDraft: vi.fn(),
|
||||
applyBuildDraft: vi.fn(),
|
||||
refreshDebugConversation: vi.fn(),
|
||||
saveBuildDraft: vi.fn(),
|
||||
saveAgentSoulConfig: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -18,14 +24,50 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate', () => ({
|
||||
AgentOrchestratePanel: (props: {
|
||||
bottomBar?: ReactNode
|
||||
headerAction?: ReactNode
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate', async () => {
|
||||
const { useAtom } = await import('jotai')
|
||||
const { agentComposerDraftAtom } = await import('@/features/agent-v2/agent-composer/store')
|
||||
|
||||
return {
|
||||
AgentOrchestratePanel: (props: {
|
||||
bottomAction?: ReactNode
|
||||
headerAction?: ReactNode
|
||||
isBuildDraftActive?: boolean
|
||||
readOnly?: boolean
|
||||
}) => {
|
||||
const [draft, setDraft] = useAtom(agentComposerDraftAtom)
|
||||
|
||||
return (
|
||||
<div role="region" aria-label="orchestrate-panel">
|
||||
<span>{`readonly:${props.readOnly ? 'yes' : 'no'}`}</span>
|
||||
<span>{`buildDraft:${props.isBuildDraftActive ? 'yes' : 'no'}`}</span>
|
||||
{props.headerAction}
|
||||
<input
|
||||
aria-label="local composer draft"
|
||||
value={draft.prompt}
|
||||
onChange={event => setDraft({
|
||||
...draft,
|
||||
prompt: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
{props.bottomAction}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar', () => ({
|
||||
AgentBuildDraftBar: (props: {
|
||||
changesCount: number
|
||||
disabled?: boolean
|
||||
onApply: () => void
|
||||
onDiscard: () => void
|
||||
}) => (
|
||||
<div role="region" aria-label="orchestrate-panel">
|
||||
{props.headerAction}
|
||||
{props.bottomBar}
|
||||
<div role="region" aria-label="build-draft-bar">
|
||||
<span>{`changes:${props.changesCount}`}</span>
|
||||
<button type="button" disabled={props.disabled} onClick={props.onApply}>apply build draft</button>
|
||||
<button type="button" disabled={props.disabled} onClick={props.onDiscard}>discard build draft</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@@ -34,34 +76,97 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-bac
|
||||
AgentBuildPanelBackground: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-chat', () => ({
|
||||
AgentBuildChat: (props: {
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
}) => (
|
||||
<div role="region" aria-label="build-chat">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.onSaveDraftBeforeRun?.()
|
||||
}}
|
||||
>
|
||||
start build
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-chat', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
AgentBuildChat: (props: {
|
||||
conversationId?: string | null
|
||||
onConversationComplete?: () => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSendInterrupted?: () => void
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
}) => {
|
||||
const [messageSent, setMessageSent] = useState(false)
|
||||
const [sentPrompt, setSentPrompt] = useState<string | undefined>()
|
||||
|
||||
return (
|
||||
<div role="region" aria-label="build-chat">
|
||||
<span>{`build:${props.conversationId ?? 'none'}`}</span>
|
||||
<span>{`sent:${messageSent ? 'yes' : 'no'}`}</span>
|
||||
<span>{`prompt:${sentPrompt ?? 'none'}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.onSaveDraftBeforeRun?.().then((agentSoulConfig) => {
|
||||
setSentPrompt(agentSoulConfig?.prompt?.system_prompt)
|
||||
setMessageSent(true)
|
||||
props.onConversationIdChange?.('build-conversation-new')
|
||||
})
|
||||
}}
|
||||
>
|
||||
send build message
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onConversationComplete?.()}>
|
||||
complete build conversation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMessageSent(true)
|
||||
props.onSendInterrupted?.()
|
||||
}}
|
||||
>
|
||||
fail build conversation
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/agent-v2/agent-soul-config', () => ({
|
||||
useWorkflowInlineAgentConfigureSync: () => ({
|
||||
draftSavedAt: undefined,
|
||||
saveAgentSoulConfig: mocks.saveAgentSoulConfig,
|
||||
saveDraft: mocks.saveDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/build-draft-query', () => ({
|
||||
agentConfigureConsoleQuery: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
buildDraft: {
|
||||
get: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['build-draft'],
|
||||
queryFn: mocks.loadBuildDraft,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
get: {
|
||||
queryKey: () => ['agent-detail'],
|
||||
},
|
||||
debugConversation: {
|
||||
refresh: {
|
||||
post: {
|
||||
mutationOptions: (options?: { onSuccess?: (data: { debug_conversation_id: string }) => void }) => ({
|
||||
mutationFn: mocks.refreshDebugConversation,
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
get: {
|
||||
queryOptions: vi.fn(),
|
||||
@@ -71,6 +176,17 @@ vi.mock('@/service/client', () => ({
|
||||
get: {
|
||||
queryOptions: () => ({ queryKey: ['build-draft'] }),
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.deleteBuildDraft }),
|
||||
},
|
||||
put: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.saveBuildDraft }),
|
||||
},
|
||||
apply: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.applyBuildDraft }),
|
||||
},
|
||||
},
|
||||
checkout: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.checkoutBuildDraft }),
|
||||
@@ -82,8 +198,17 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function createInlineComposerState(): WorkflowAgentComposerResponse {
|
||||
function createInlineComposerState({
|
||||
snapshotId = 'snapshot-1',
|
||||
systemPrompt = 'Help with workflow tasks.',
|
||||
}: {
|
||||
snapshotId?: string
|
||||
systemPrompt?: string
|
||||
} = {}): WorkflowAgentComposerResponse {
|
||||
return {
|
||||
active_config_snapshot: {
|
||||
id: snapshotId,
|
||||
},
|
||||
agent: {
|
||||
id: 'agent-1',
|
||||
icon: 'A',
|
||||
@@ -94,14 +219,14 @@ function createInlineComposerState(): WorkflowAgentComposerResponse {
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Help with workflow tasks.',
|
||||
system_prompt: systemPrompt,
|
||||
},
|
||||
} satisfies AgentSoulConfig,
|
||||
binding: {
|
||||
id: 'binding-1',
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'agent-1',
|
||||
current_snapshot_id: 'snapshot-1',
|
||||
current_snapshot_id: snapshotId,
|
||||
workflow_id: 'workflow-1',
|
||||
node_id: 'node-1',
|
||||
},
|
||||
@@ -111,25 +236,51 @@ function createInlineComposerState(): WorkflowAgentComposerResponse {
|
||||
describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.loadBuildDraft.mockRejectedValue(new Response(null, { status: 404 }))
|
||||
mocks.checkoutBuildDraft.mockResolvedValue({
|
||||
agent_soul: {},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.deleteBuildDraft.mockResolvedValue({ result: 'success' })
|
||||
mocks.applyBuildDraft.mockResolvedValue({
|
||||
agent_soul: {},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.refreshDebugConversation.mockResolvedValue({
|
||||
debug_conversation_id: 'build-conversation-refreshed',
|
||||
})
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Help with workflow tasks.',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.saveDraft.mockResolvedValue(createInlineComposerState())
|
||||
mocks.saveAgentSoulConfig.mockResolvedValue(createInlineComposerState())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderWorkspace(props: {
|
||||
inlineComposerState?: WorkflowAgentComposerResponse
|
||||
onSaveInlineToRoster?: () => void
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
render(
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowInlineAgentConfigureWorkspace
|
||||
agentId="agent-1"
|
||||
appId="app-1"
|
||||
inlineComposerState={createInlineComposerState()}
|
||||
inlineComposerState={props.inlineComposerState ?? createInlineComposerState()}
|
||||
nodeId="node-1"
|
||||
onSaveInlineToRoster={props.onSaveInlineToRoster}
|
||||
open
|
||||
@@ -139,12 +290,12 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
}
|
||||
|
||||
describe('Working Directory', () => {
|
||||
it('should show save-to-roster in the configure header menu without rendering the old action bar', () => {
|
||||
it('should show save-to-roster in the configure header menu without rendering the old action bar', async () => {
|
||||
renderWorkspace({
|
||||
onSaveInlineToRoster: vi.fn(),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.more' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'common.operation.more' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -152,7 +303,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
it('should show the working directory panel when the header action is clicked', async () => {
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
fireEvent.click(await screen.findByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.workingDirectory.open',
|
||||
}))
|
||||
|
||||
@@ -166,28 +317,372 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
})
|
||||
|
||||
describe('Build Chat', () => {
|
||||
it('should save the workflow agent draft and checkout a build draft before starting build chat', async () => {
|
||||
it('should save the workflow agent draft and write that snapshot into the build draft before starting build chat', async () => {
|
||||
mocks.saveDraft.mockResolvedValue(createInlineComposerState({
|
||||
snapshotId: 'snapshot-saved',
|
||||
systemPrompt: 'Saved workflow snapshot prompt.',
|
||||
}))
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'start build' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => expect(mocks.saveDraft).toHaveBeenCalled())
|
||||
expect(mocks.checkoutBuildDraft).toHaveBeenCalledWith({
|
||||
expect(mocks.saveBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
force: false,
|
||||
variant: 'agent_app',
|
||||
save_strategy: 'save_to_current_version',
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Saved workflow snapshot prompt.',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}, expect.any(Object))
|
||||
const saveDraftCallOrder = mocks.saveDraft.mock.invocationCallOrder[0]
|
||||
const checkoutBuildDraftCallOrder = mocks.checkoutBuildDraft.mock.invocationCallOrder[0]
|
||||
const saveBuildDraftCallOrder = mocks.saveBuildDraft.mock.invocationCallOrder[0]
|
||||
expect(saveDraftCallOrder).toBeDefined()
|
||||
expect(checkoutBuildDraftCallOrder).toBeDefined()
|
||||
if (saveDraftCallOrder === undefined || checkoutBuildDraftCallOrder === undefined)
|
||||
throw new Error('Expected save draft and checkout mutations to be called')
|
||||
expect(saveBuildDraftCallOrder).toBeDefined()
|
||||
if (saveDraftCallOrder === undefined || saveBuildDraftCallOrder === undefined)
|
||||
throw new Error('Expected workflow draft and build draft saves to be called')
|
||||
|
||||
expect(saveDraftCallOrder).toBeLessThan(checkoutBuildDraftCallOrder)
|
||||
expect(saveDraftCallOrder).toBeLessThan(saveBuildDraftCallOrder)
|
||||
expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use the saved build draft response as the build chat source', async () => {
|
||||
mocks.saveDraft.mockResolvedValue(createInlineComposerState({
|
||||
snapshotId: 'snapshot-saved',
|
||||
systemPrompt: 'Saved workflow snapshot prompt.',
|
||||
}))
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Normalized build draft prompt.',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
|
||||
})
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('prompt:Normalized build draft prompt.')
|
||||
})
|
||||
|
||||
it('should enter build draft mode without resetting the current inline build chat', async () => {
|
||||
mocks.loadBuildDraft
|
||||
.mockRejectedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
expect(await screen.findByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
})).toBeDisabled()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes'))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:yes')
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
})).toBeDisabled()
|
||||
|
||||
vi.useFakeTimers()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(mocks.loadBuildDraft).toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
})).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should re-enable inline build draft actions when build chat fails', async () => {
|
||||
mocks.loadBuildDraft
|
||||
.mockRejectedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'fail build conversation' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
})).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should not let a previous inline build completion refresh unlock a new build run', async () => {
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
await screen.findByRole('region', { name: 'build-draft-bar' })
|
||||
const initialBuildDraftLoadCount = mocks.loadBuildDraft.mock.calls.length
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled())
|
||||
|
||||
vi.useFakeTimers()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(mocks.loadBuildDraft).toHaveBeenCalledTimes(initialBuildDraftLoadCount)
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should refresh the inline build debug conversation when restarting after a build send', async () => {
|
||||
mocks.loadBuildDraft
|
||||
.mockRejectedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
await waitFor(() => expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'fail build conversation' }))
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
}))
|
||||
|
||||
await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object)))
|
||||
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
})
|
||||
|
||||
it('should apply inline build draft through the workflow node composer owner', async () => {
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Applied inline build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'apply build draft' }))
|
||||
|
||||
await waitFor(() => expect(mocks.saveAgentSoulConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Applied inline build prompt',
|
||||
}),
|
||||
})))
|
||||
expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(mocks.applyBuildDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep exiting inline build draft when debug conversation refresh fails after applying', async () => {
|
||||
mocks.refreshDebugConversation.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Applied inline build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'apply build draft' }))
|
||||
|
||||
await waitFor(() => expect(mocks.saveAgentSoulConfig).toHaveBeenCalled())
|
||||
expect(mocks.deleteBuildDraft).toHaveBeenCalled()
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no')
|
||||
})
|
||||
|
||||
it('should refresh the inline build debug conversation when discarding the build draft', async () => {
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Discarded inline build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'discard build draft' }))
|
||||
|
||||
await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object)))
|
||||
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
})
|
||||
|
||||
it('should keep exiting inline build draft when debug conversation refresh fails after discarding', async () => {
|
||||
mocks.refreshDebugConversation.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Discarded inline build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'discard build draft' }))
|
||||
|
||||
await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalled())
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no')
|
||||
})
|
||||
|
||||
it('should keep the composer session mounted when the inline snapshot changes', async () => {
|
||||
const { rerender } = renderWorkspace({
|
||||
inlineComposerState: createInlineComposerState({ snapshotId: 'snapshot-1' }),
|
||||
})
|
||||
const localDraftInput = await screen.findByRole('textbox', { name: 'local composer draft' })
|
||||
|
||||
fireEvent.change(localDraftInput, { target: { value: 'draft still mounted' } })
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<WorkflowInlineAgentConfigureWorkspace
|
||||
agentId="agent-1"
|
||||
appId="app-1"
|
||||
inlineComposerState={createInlineComposerState({ snapshotId: 'snapshot-2' })}
|
||||
nodeId="node-1"
|
||||
open
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'local composer draft' })).toHaveValue('draft still mounted')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+290
-34
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentConfigSnapshotSummaryResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentAppDetailWithSite, AgentConfigSnapshotSummaryResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentComposerBindingResponse, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -8,24 +8,37 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtom, useAtomValue, useStore as useJotaiStore, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDefaultModel, useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { agentComposerDraftAtom, rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { AgentOrchestratePanel } from '@/features/agent-v2/agent-detail/configure/components/orchestrate'
|
||||
import { AgentBuildDraftBar } from '@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar'
|
||||
import { AgentBuildPanelBackground } from '@/features/agent-v2/agent-detail/configure/components/preview/build-background'
|
||||
import { AgentBuildChat } from '@/features/agent-v2/agent-detail/configure/components/preview/build-chat'
|
||||
import { AgentPreviewHeader } from '@/features/agent-v2/agent-detail/configure/components/preview/header'
|
||||
import { AgentConfigureRightPanelChat } from '@/features/agent-v2/agent-detail/configure/components/preview/right-panel-chat'
|
||||
import { useAgentWorkingDirectoryPanel } from '@/features/agent-v2/agent-detail/configure/components/preview/use-working-directory-panel'
|
||||
import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from '@/features/agent-v2/agent-detail/configure/components/workspace'
|
||||
import { useAgentPreviewSoulConfig } from '@/features/agent-v2/agent-detail/configure/hooks'
|
||||
import { usePrepareAgentBuildDraftBeforeRun } from '@/features/agent-v2/agent-detail/configure/use-agent-build-draft-run'
|
||||
import {
|
||||
agentConfigureBuildDraftActionsDisabledAtom,
|
||||
agentConfigureClearPreviewChatAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
agentConfigureRightPanelChatModeAtom,
|
||||
agentConfigureScopedAtoms,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
resetAgentConfigureConversationAtom,
|
||||
setAgentConfigureConversationIdAtom,
|
||||
} from '@/features/agent-v2/agent-detail/configure/state'
|
||||
import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData } from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
|
||||
|
||||
@@ -150,19 +163,63 @@ export function WorkflowInlineAgentConfigureWorkspace(props: WorkflowInlineAgent
|
||||
)
|
||||
}
|
||||
|
||||
const composerSessionKey = `${nodeId}:${agentId}:${activeConfigSnapshot?.id ?? 'draft'}`
|
||||
const composerSessionKey = `${nodeId}:${agentId}`
|
||||
|
||||
return (
|
||||
<ScopeProvider key={composerSessionKey} atoms={agentConfigureScopedAtoms} name="WorkflowInlineAgentConfigure">
|
||||
<WorkflowInlineAgentConfigureWorkspaceComposerScope
|
||||
{...props}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentId={agentId}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
/>
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowInlineAgentConfigureWorkspaceComposerScope({
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
activeConfigSnapshot,
|
||||
...props
|
||||
}: Omit<WorkflowInlineAgentConfigureWorkspaceProps, 'agentId'> & {
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentId: string
|
||||
agentSoulConfig: AgentSoulConfig
|
||||
}) {
|
||||
const soulSourceOverride = useAtomValue(agentConfigureSoulSourceOverrideAtom)
|
||||
const setSoulSourceOverride = useSetAtom(agentConfigureSoulSourceOverrideAtom)
|
||||
const buildDraft = useAgentConfigureBuildDraftData({
|
||||
agentId,
|
||||
activeVersionId: activeConfigSnapshot?.id,
|
||||
composerAgentSoulConfig: agentSoulConfig,
|
||||
isViewingVersion: false,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
setSoulSourceOverride,
|
||||
soulSourceOverride,
|
||||
})
|
||||
const composerSessionKey = `${props.nodeId}:${agentId}`
|
||||
|
||||
if (buildDraft.isPending) {
|
||||
return (
|
||||
<div className="flex h-full min-h-80 items-center justify-center bg-components-panel-bg">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(agentSoulConfig)}
|
||||
initialOriginalConfig={agentSoulConfig}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
>
|
||||
<WorkflowInlineAgentConfigureWorkspaceContent
|
||||
{...props}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentId={agentId}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
buildDraft={buildDraft}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
)
|
||||
@@ -173,6 +230,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
appId,
|
||||
buildDraft,
|
||||
inlineComposerState,
|
||||
nodeId,
|
||||
onClose,
|
||||
@@ -183,14 +241,25 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentId: string
|
||||
agentSoulConfig: AgentSoulConfig
|
||||
buildDraft: ReturnType<typeof useAgentConfigureBuildDraftData>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [clearChatList, setClearChatList] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const { t } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const jotaiStore = useJotaiStore()
|
||||
const workingDirectoryPanel = useAgentWorkingDirectoryPanel()
|
||||
const composerState = inlineComposerState
|
||||
const buildDraftActionsDisabled = useAtomValue(agentConfigureBuildDraftActionsDisabledAtom)
|
||||
const clearPreviewChat = useAtomValue(agentConfigureClearPreviewChatAtom)
|
||||
const conversationIds = useAtomValue(agentConfigureConversationIdsAtom)
|
||||
const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom)
|
||||
const resetConversation = useSetAtom(resetAgentConfigureConversationAtom)
|
||||
const setBuildDraftActionsDisabled = useSetAtom(agentConfigureBuildDraftActionsDisabledAtom)
|
||||
const setClearPreviewChat = useSetAtom(agentConfigureClearPreviewChatAtom)
|
||||
const setConversationId = useSetAtom(setAgentConfigureConversationIdAtom)
|
||||
const rebaseComposerDraft = useSetAtom(rebaseAgentComposerDraftAtom)
|
||||
const { currentModel, setConfigureModel, textGenerationModelList } = useAgentOrchestrateModelOptions()
|
||||
const { draftSavedAt, saveDraft } = useWorkflowInlineAgentConfigureSync({
|
||||
const [isApplyingInlineBuildDraft, setIsApplyingInlineBuildDraft] = useState(false)
|
||||
const { draftSavedAt, saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({
|
||||
nodeId,
|
||||
baseConfig: agentSoulConfig,
|
||||
currentModel,
|
||||
@@ -206,14 +275,166 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
|
||||
onSaved?.(binding)
|
||||
},
|
||||
enabled: open && !!agentSoulConfig,
|
||||
enabled: open && !!agentSoulConfig && !buildDraft.isActive,
|
||||
})
|
||||
const buildDraftRun = usePrepareAgentBuildDraftBeforeRun({
|
||||
const refreshDebugConversationMutation = useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({
|
||||
onSuccess: ({ debug_conversation_id }) => {
|
||||
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
|
||||
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
(agentDetail) => {
|
||||
if (!agentDetail)
|
||||
return agentDetail
|
||||
|
||||
return {
|
||||
...agentDetail,
|
||||
debug_conversation_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
}))
|
||||
const {
|
||||
mutateAsync: refreshDebugConversationRequestAsync,
|
||||
isPending: isRefreshingDebugConversation,
|
||||
} = refreshDebugConversationMutation
|
||||
const refreshDebugConversationInput = useCallback(() => ({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
}), [agentId])
|
||||
const refreshDebugConversationAsync = useCallback(() => {
|
||||
return refreshDebugConversationRequestAsync(refreshDebugConversationInput())
|
||||
}, [refreshDebugConversationInput, refreshDebugConversationRequestAsync])
|
||||
const resetBuildChatSession = useCallback(async () => {
|
||||
await refreshDebugConversationAsync().catch(() => undefined)
|
||||
setConversationId({ mode: 'build', conversationId: null })
|
||||
setClearPreviewChat(true)
|
||||
}, [refreshDebugConversationAsync, setClearPreviewChat, setConversationId])
|
||||
const rebaseComposerDraftFromSoulConfig = useCallback((agentSoulConfig?: AgentSoulConfig) => {
|
||||
rebaseComposerDraft({
|
||||
draft: agentSoulConfigToFormState(agentSoulConfig),
|
||||
originalConfig: agentSoulConfig,
|
||||
})
|
||||
}, [rebaseComposerDraft])
|
||||
const buildDraftActions = useAgentConfigureBuildDraftActions({
|
||||
agentId,
|
||||
isBuildDraftActive: false,
|
||||
saveDraft,
|
||||
isActive: buildDraft.isActive,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
rebaseComposerDraft: rebaseComposerDraftFromSoulConfig,
|
||||
refetchBuildDraft: buildDraft.refetch,
|
||||
refetchComposer: async () => ({
|
||||
data: {
|
||||
agent_soul: agentSoulConfig,
|
||||
},
|
||||
}),
|
||||
resetBuildChatSession,
|
||||
saveDraft: async () => {
|
||||
await saveDraft()
|
||||
},
|
||||
setSoulSourceOverride: buildDraft.setSoulSourceOverride,
|
||||
})
|
||||
const previewAgentSoulConfig = useAgentPreviewSoulConfig(agentSoulConfig as AgentSoulConfig | undefined)
|
||||
const { cancelBuildDraftRefresh } = buildDraftActions
|
||||
const buildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { mutateAsync: saveBuildDraft } = useMutation(consoleQuery.agent.byAgentId.buildDraft.put.mutationOptions())
|
||||
const discardBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions())
|
||||
const getInlineAgentSoulDraft = useCallback(() => formStateToAgentSoulConfig({
|
||||
baseConfig: agentSoulConfig,
|
||||
formState: jotaiStore.get(agentComposerDraftAtom),
|
||||
currentModel,
|
||||
}), [agentSoulConfig, currentModel, jotaiStore])
|
||||
const prepareInlineBuildDraftBeforeRun = useCallback(async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
const configSnapshot = getInlineAgentSoulDraft()
|
||||
const savedComposerState = await saveDraft()
|
||||
const preparedAgentSoulConfig = savedComposerState?.agent_soul ?? configSnapshot
|
||||
const buildDraftState = await saveBuildDraft({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
body: {
|
||||
variant: 'agent_app',
|
||||
save_strategy: 'save_to_current_version',
|
||||
agent_soul: preparedAgentSoulConfig,
|
||||
},
|
||||
})
|
||||
|
||||
const savedBuildAgentSoulConfig = buildDraftState.agent_soul ?? preparedAgentSoulConfig
|
||||
queryClient.setQueryData(buildDraftQueryOptions.queryKey, buildDraftState)
|
||||
rebaseComposerDraftFromSoulConfig(savedBuildAgentSoulConfig)
|
||||
buildDraft.setSoulSourceOverride('build-draft')
|
||||
return savedBuildAgentSoulConfig
|
||||
}, [agentId, buildDraft, buildDraftQueryOptions.queryKey, cancelBuildDraftRefresh, getInlineAgentSoulDraft, queryClient, rebaseComposerDraftFromSoulConfig, saveBuildDraft, saveDraft])
|
||||
const applyInlineBuildDraft = async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
setIsApplyingInlineBuildDraft(true)
|
||||
try {
|
||||
if (!buildDraft.agentSoulConfig)
|
||||
return
|
||||
|
||||
const savedComposerState = await saveAgentSoulConfig(buildDraft.agentSoulConfig)
|
||||
await discardBuildDraftMutation.mutateAsync({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
}).catch(() => undefined)
|
||||
await resetBuildChatSession().catch(() => undefined)
|
||||
buildDraft.setSoulSourceOverride('draft')
|
||||
queryClient.removeQueries({
|
||||
queryKey: buildDraftQueryOptions.queryKey,
|
||||
})
|
||||
rebaseComposerDraftFromSoulConfig(savedComposerState?.agent_soul ?? buildDraft.agentSoulConfig)
|
||||
toast.success(t('api.actionSuccess'))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('api.actionFailed'))
|
||||
}
|
||||
finally {
|
||||
setIsApplyingInlineBuildDraft(false)
|
||||
}
|
||||
}
|
||||
const discardInlineBuildDraft = async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
try {
|
||||
await discardBuildDraftMutation.mutateAsync({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
})
|
||||
await resetBuildChatSession().catch(() => undefined)
|
||||
buildDraft.setSoulSourceOverride('draft')
|
||||
queryClient.removeQueries({
|
||||
queryKey: buildDraftQueryOptions.queryKey,
|
||||
})
|
||||
rebaseComposerDraftFromSoulConfig(agentSoulConfig)
|
||||
toast.success(t('api.actionSuccess'))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('api.actionFailed'))
|
||||
}
|
||||
}
|
||||
const hasRestartCurrentChatTarget = !!conversationIds[rightPanelChatMode] || buildDraft.isActive
|
||||
const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget
|
||||
|| buildDraftActionsDisabled
|
||||
|| isApplyingInlineBuildDraft
|
||||
|| discardBuildDraftMutation.isPending
|
||||
|| isRefreshingDebugConversation
|
||||
const restartCurrentChat = () => {
|
||||
if (isRestartCurrentChatDisabled)
|
||||
return
|
||||
|
||||
if (buildDraft.isActive) {
|
||||
void discardInlineBuildDraft()
|
||||
return
|
||||
}
|
||||
|
||||
resetConversation(rightPanelChatMode)
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentConfigureWorkspace
|
||||
@@ -224,12 +445,30 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
appId={appId}
|
||||
nodeId={nodeId}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
agentSoulConfig={buildDraft.agentSoulConfig}
|
||||
agentName={composerState?.agent?.name}
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
draftSavedAt={draftSavedAt}
|
||||
readOnly={buildDraft.isActive}
|
||||
isBuildDraftActive={buildDraft.isActive}
|
||||
showPublishBar={false}
|
||||
bottomAction={buildDraft.isActive
|
||||
? (
|
||||
<AgentBuildDraftBar
|
||||
changesCount={buildDraft.changesCount}
|
||||
disabled={buildDraftActionsDisabled}
|
||||
isApplying={isApplyingInlineBuildDraft}
|
||||
isDiscarding={discardBuildDraftMutation.isPending}
|
||||
onApply={() => {
|
||||
void applyInlineBuildDraft()
|
||||
}}
|
||||
onDiscard={() => {
|
||||
void discardInlineBuildDraft()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
headerAction={onSaveInlineToRoster
|
||||
? <WorkflowInlineAgentConfigureMoreAction onSaveInlineToRoster={onSaveInlineToRoster} />
|
||||
: undefined}
|
||||
@@ -252,17 +491,15 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
onModeChange={() => undefined}
|
||||
onToggleChatFeatures={() => undefined}
|
||||
onOpenWorkingDirectory={workingDirectoryPanel.openWorkingDirectory}
|
||||
onRefresh={() => {
|
||||
setConversationId(null)
|
||||
setClearChatList(true)
|
||||
}}
|
||||
onRefresh={restartCurrentChat}
|
||||
refreshDisabled={isRestartCurrentChatDisabled}
|
||||
showChatFeaturesAction={false}
|
||||
trailingAction={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
aria-label={t('operation.close')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</button>
|
||||
@@ -270,19 +507,38 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
/>
|
||||
)}
|
||||
chat={(
|
||||
<AgentBuildChat
|
||||
<AgentConfigureRightPanelChat
|
||||
agentId={agentId}
|
||||
agentIcon={composerState?.agent?.icon}
|
||||
agentIconBackground={composerState?.agent?.icon_background}
|
||||
agentIconType={composerState?.agent?.icon_type as Parameters<typeof AgentBuildChat>[0]['agentIconType']}
|
||||
agentIconType={composerState?.agent?.icon_type as Parameters<typeof AgentConfigureRightPanelChat>[0]['agentIconType']}
|
||||
agentName={composerState?.agent?.name}
|
||||
agentSoulConfig={previewAgentSoulConfig}
|
||||
clearChatList={clearChatList}
|
||||
conversationId={conversationId}
|
||||
agentSoulConfig={buildDraft.agentSoulConfig}
|
||||
clearChatList={clearPreviewChat}
|
||||
conversationIds={conversationIds}
|
||||
draftType="debug_build"
|
||||
onClearChatListChange={setClearChatList}
|
||||
onConversationIdChange={setConversationId}
|
||||
onSaveDraftBeforeRun={buildDraftRun.prepareBuildDraftBeforeRun}
|
||||
mode={rightPanelChatMode}
|
||||
onClearChatListChange={setClearPreviewChat}
|
||||
onConversationComplete={(mode) => {
|
||||
if (mode === 'build')
|
||||
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setBuildDraftActionsDisabled(false))
|
||||
}}
|
||||
onConversationIdChange={(mode, conversationId) => {
|
||||
setConversationId({ mode, conversationId })
|
||||
}}
|
||||
onSaveDraftBeforeRun={async () => {
|
||||
setBuildDraftActionsDisabled(true)
|
||||
try {
|
||||
return await prepareInlineBuildDraftBeforeRun()
|
||||
}
|
||||
catch (error) {
|
||||
setBuildDraftActionsDisabled(false)
|
||||
throw error
|
||||
}
|
||||
}}
|
||||
onSendInterrupted={() => {
|
||||
setBuildDraftActionsDisabled(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentSoulConfigWithFiles } from '../conversions'
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conversions'
|
||||
import { defaultAgentSoulConfigFormState } from '../form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
rebaseAgentComposerDraftAtom,
|
||||
} from '../store'
|
||||
|
||||
describe('agent composer store conversions', () => {
|
||||
it('rebases draft baselines through the composer state action', () => {
|
||||
const store = createStore()
|
||||
const nextDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Build draft prompt',
|
||||
}
|
||||
const originalConfig = {
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
} satisfies AgentSoulConfig
|
||||
|
||||
store.set(rebaseAgentComposerDraftAtom, {
|
||||
draft: nextDraft,
|
||||
originalConfig,
|
||||
})
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerOriginalDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerPublishedDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe('Build draft prompt')
|
||||
})
|
||||
|
||||
it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => {
|
||||
const baseConfig: AgentSoulConfigWithFiles = {
|
||||
app_features: {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentSoulConfigFormState } from './form-state'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useRef } from 'react'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { defaultAgentSoulConfigFormState } from './form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
@@ -12,27 +12,6 @@ import {
|
||||
agentComposerPublishedDraftAtom,
|
||||
} from './store'
|
||||
|
||||
function createAgentComposerStore({
|
||||
initialDraft,
|
||||
initialOriginalConfig,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
}) {
|
||||
const store = createStore()
|
||||
|
||||
if (initialOriginalConfig)
|
||||
store.set(agentComposerOriginalConfigAtom, initialOriginalConfig)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerDraftAtom, initialDraft)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerOriginalDraftAtom, initialDraft)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerPublishedDraftAtom, initialDraft)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
export function AgentComposerProvider({
|
||||
children,
|
||||
initialDraft,
|
||||
@@ -42,18 +21,19 @@ export function AgentComposerProvider({
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
}) {
|
||||
const storeRef = useRef<ReturnType<typeof createAgentComposerStore> | null>(null)
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createAgentComposerStore({
|
||||
initialDraft,
|
||||
initialOriginalConfig,
|
||||
})
|
||||
}
|
||||
const store = storeRef.current
|
||||
const draft = initialDraft ?? defaultAgentSoulConfigFormState
|
||||
|
||||
return (
|
||||
<JotaiProvider store={store}>
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[agentComposerOriginalConfigAtom, initialOriginalConfig],
|
||||
[agentComposerDraftAtom, draft],
|
||||
[agentComposerOriginalDraftAtom, draft],
|
||||
[agentComposerPublishedDraftAtom, draft],
|
||||
]}
|
||||
name="AgentComposer"
|
||||
>
|
||||
{children}
|
||||
</JotaiProvider>
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,19 @@ export const agentComposerOriginalDraftAtom = atom<AgentSoulConfigFormState | un
|
||||
export const agentComposerPublishedDraftAtom = atom<AgentSoulConfigFormState | undefined>(defaultAgentSoulConfigFormState)
|
||||
export const agentComposerDraftAtom = atom<AgentSoulConfigFormState>(defaultAgentSoulConfigFormState)
|
||||
|
||||
export const rebaseAgentComposerDraftAtom = atom(null, (_get, set, {
|
||||
draft,
|
||||
originalConfig,
|
||||
}: {
|
||||
draft: AgentSoulConfigFormState
|
||||
originalConfig?: AgentSoulConfig
|
||||
}) => {
|
||||
set(agentComposerOriginalConfigAtom, originalConfig)
|
||||
set(agentComposerDraftAtom, draft)
|
||||
set(agentComposerOriginalDraftAtom, draft)
|
||||
set(agentComposerPublishedDraftAtom, draft)
|
||||
})
|
||||
|
||||
export const isAgentComposerDirtyAtom = atom((get) => {
|
||||
const originalDraft = get(agentComposerOriginalDraftAtom)
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Agent Configure
|
||||
|
||||
Owns the Agent V2 configure runtime used by the Agent App configure page and workflow inline Agent configure surface, including editable composer draft wiring, build chat sessions, version viewing, build draft mode, and preview side panels.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
- agent-composer
|
||||
- agent-detail/configure/state
|
||||
- agent-detail/configure/use-agent-build-draft-run
|
||||
- agent-detail/configure/use-agent-configure-build-draft
|
||||
- agent-detail/configure/use-agent-configure-sync
|
||||
- agent-detail/configure/components/orchestrate
|
||||
- agent-detail/configure/components/preview
|
||||
- agent-detail/configure/components/workspace
|
||||
|
||||
## External Modules
|
||||
|
||||
- app/components/base/chat
|
||||
- app/components/base/action-button
|
||||
- app/components/base/app-icon
|
||||
- app/components/base/features
|
||||
- app/components/base/file-uploader
|
||||
- app/components/base/infotip
|
||||
- app/components/base/loading
|
||||
- app/components/base/prompt-editor
|
||||
- app/components/base/skeleton
|
||||
- app/components/app/configuration/config/agent/agent-tools
|
||||
- app/components/datasets
|
||||
- app/components/header/account-setting/model-provider-page
|
||||
- app/components/plugins
|
||||
- app/components/tools
|
||||
- app/components/workflow/block-icon
|
||||
- app/components/workflow/block-selector
|
||||
- app/components/workflow/hooks/use-serial-async-callback
|
||||
- app/components/workflow/nodes
|
||||
- app/components/workflow/types
|
||||
- config
|
||||
- context/app-context
|
||||
- context/i18n
|
||||
- context/modal-context
|
||||
- contract/router
|
||||
- hooks/use-format-time-from-now
|
||||
- hooks/use-theme
|
||||
- hooks/use-timestamp
|
||||
- models/datasets
|
||||
- models/debug
|
||||
- models/log
|
||||
- service/base
|
||||
- service/use-common
|
||||
- types/app
|
||||
- types/common
|
||||
- types/i18n
|
||||
- types/workflow
|
||||
- utils/format
|
||||
- utils/var
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AgentConfigurePage } from '../page'
|
||||
@@ -51,6 +51,15 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
|
||||
@@ -82,6 +91,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
get: {
|
||||
key: () => ['agents'],
|
||||
},
|
||||
byAgentId: {
|
||||
get: {
|
||||
queryOptions: () => ({ queryKey: ['agent'] }),
|
||||
@@ -199,43 +211,56 @@ vi.mock('../components/orchestrate', async () => {
|
||||
vi.mock('../components/orchestrate/build-draft-bar', () => ({
|
||||
AgentBuildDraftBar: (props: {
|
||||
changesCount: number
|
||||
disabled?: boolean
|
||||
onApply: () => void
|
||||
onDiscard: () => void
|
||||
}) => (
|
||||
<div role="region" aria-label="build-draft-bar">
|
||||
<span>{`changes:${props.changesCount}`}</span>
|
||||
<button type="button" onClick={props.onApply}>apply build draft</button>
|
||||
<button type="button" onClick={props.onDiscard}>discard build draft</button>
|
||||
<button type="button" disabled={props.disabled} onClick={props.onApply}>apply build draft</button>
|
||||
<button type="button" disabled={props.disabled} onClick={props.onDiscard}>discard build draft</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/preview/build-chat', () => ({
|
||||
AgentBuildChat: (props: {
|
||||
conversationId?: string | null
|
||||
onConversationComplete?: () => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
}) => (
|
||||
<div role="region" aria-label="build-chat">
|
||||
<span>{`build:${props.conversationId ?? 'none'}`}</span>
|
||||
<button type="button" onClick={() => props.onConversationIdChange?.('build-conversation-new')}>
|
||||
save build conversation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.onSaveDraftBeforeRun?.()
|
||||
}}
|
||||
>
|
||||
send build message
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onConversationComplete?.()}>
|
||||
complete build conversation
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('../components/preview/build-chat', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
AgentBuildChat: (props: {
|
||||
conversationId?: string | null
|
||||
onConversationComplete?: () => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
}) => {
|
||||
const [messageSent, setMessageSent] = useState(false)
|
||||
|
||||
return (
|
||||
<div role="region" aria-label="build-chat">
|
||||
<span>{`build:${props.conversationId ?? 'none'}`}</span>
|
||||
<span>{`sent:${messageSent ? 'yes' : 'no'}`}</span>
|
||||
<button type="button" onClick={() => props.onConversationIdChange?.('build-conversation-new')}>
|
||||
save build conversation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.onSaveDraftBeforeRun?.().then(() => {
|
||||
setMessageSent(true)
|
||||
props.onConversationIdChange?.('build-conversation-new')
|
||||
})
|
||||
}}
|
||||
>
|
||||
send build message
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onConversationComplete?.()}>
|
||||
complete build conversation
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../components/preview/preview-chat', () => ({
|
||||
AgentPreviewChat: (props: {
|
||||
@@ -252,7 +277,20 @@ vi.mock('../components/preview/preview-chat', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../components/preview/chat-features-panel', () => ({
|
||||
AgentChatFeaturesPanel: () => null,
|
||||
AgentChatFeaturesPanel: (props: {
|
||||
appFeatures?: {
|
||||
opening_statement?: string
|
||||
}
|
||||
disabled?: boolean
|
||||
show: boolean
|
||||
}) => props.show
|
||||
? (
|
||||
<div role="region" aria-label="chat-features-panel">
|
||||
<span>{`chatFeaturesDisabled:${props.disabled ? 'yes' : 'no'}`}</span>
|
||||
<span>{`opening:${props.appFeatures?.opening_statement ?? ''}`}</span>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('../components/preview/header', () => ({
|
||||
@@ -260,8 +298,10 @@ vi.mock('../components/preview/header', () => ({
|
||||
mode: 'build' | 'preview'
|
||||
previewEnabled: boolean
|
||||
onModeChange: (mode: 'build' | 'preview') => void
|
||||
onToggleChatFeatures: () => void
|
||||
onOpenWorkingDirectory: () => void
|
||||
onRefresh: () => void
|
||||
refreshDisabled?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
<div>{props.mode}</div>
|
||||
@@ -271,10 +311,13 @@ vi.mock('../components/preview/header', () => ({
|
||||
<button type="button" onClick={() => props.onModeChange('build')}>
|
||||
build mode
|
||||
</button>
|
||||
<button type="button" onClick={props.onToggleChatFeatures}>
|
||||
chat features
|
||||
</button>
|
||||
<button type="button" onClick={props.onOpenWorkingDirectory}>
|
||||
open working directory
|
||||
</button>
|
||||
<button type="button" onClick={props.onRefresh}>
|
||||
<button type="button" disabled={props.refreshDisabled} onClick={props.onRefresh}>
|
||||
restart preview
|
||||
</button>
|
||||
</div>
|
||||
@@ -395,9 +438,6 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'build-conversation-new',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
@@ -438,6 +478,40 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.queryByRole('region', { name: 'preview-chat', hidden: true })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable restart when there is no conversation or build draft to reset', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.agent = {
|
||||
...mocks.queryState.agent,
|
||||
data: {
|
||||
...mocks.queryState.agent.data,
|
||||
debug_conversation_id: '',
|
||||
},
|
||||
}
|
||||
mocks.queryState.composer = {
|
||||
data: {},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
const restartButton = screen.getByRole('button', { name: 'restart preview' })
|
||||
|
||||
expect(restartButton).toBeDisabled()
|
||||
|
||||
await user.click(restartButton)
|
||||
|
||||
expect(mocks.refreshDebugConversation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stay in normal draft mode when build draft returns 404 even if a debug conversation exists', () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
@@ -477,6 +551,43 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep build draft query refresh owned by explicit workflow actions', () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
let buildDraftQueryOptions: unknown
|
||||
for (const [options] of vi.mocked(useQuery).mock.calls) {
|
||||
if (Array.isArray(options.queryKey) && options.queryKey[0] === 'build-draft') {
|
||||
buildDraftQueryOptions = options
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(buildDraftQueryOptions).toMatchObject({
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should enter build draft mode when build draft data exists', () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
@@ -524,7 +635,120 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the build draft bar after a new build conversation refresh completes', async () => {
|
||||
it('should show chat features from the active build draft source as read-only', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
app_features: {
|
||||
opening_statement: 'draft opening',
|
||||
},
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
app_features: {
|
||||
opening_statement: 'build opening',
|
||||
},
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
},
|
||||
dataUpdatedAt: 1,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'chat features' }))
|
||||
|
||||
expect(screen.getByRole('region', { name: 'chat-features-panel' })).toHaveTextContent('chatFeaturesDisabled:yes')
|
||||
expect(screen.getByRole('region', { name: 'chat-features-panel' })).toHaveTextContent('opening:build opening')
|
||||
})
|
||||
|
||||
it('should switch to build draft mode without resetting the sending chat when sending from normal draft mode', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: undefined as unknown,
|
||||
dataUpdatedAt: 0,
|
||||
error: new Response(null, { status: 404 }),
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.checkoutBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
force: false,
|
||||
},
|
||||
}, expect.any(Object))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
|
||||
})
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:yes')
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should keep the build draft bar disabled while a build conversation is responding', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
const refetchBuildDraft = vi.fn().mockResolvedValue({})
|
||||
@@ -571,7 +795,13 @@ describe('AgentConfigurePage', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
|
||||
expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
|
||||
@@ -583,6 +813,214 @@ describe('AgentConfigurePage', () => {
|
||||
|
||||
expect(refetchBuildDraft).toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should settle build draft actions when the build completion refresh fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
const refetchBuildDraft = vi.fn().mockRejectedValue(new Error('refresh failed'))
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
},
|
||||
dataUpdatedAt: 1,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: refetchBuildDraft,
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(refetchBuildDraft).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should not let a previous build completion refresh unlock a new build run', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
const refetchBuildDraft = vi.fn().mockResolvedValue({})
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
},
|
||||
dataUpdatedAt: 1,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: refetchBuildDraft,
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(refetchBuildDraft).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should ignore a previous build completion refresh that is already in flight', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
const refetchBuildDraft = vi.fn()
|
||||
const staleRefresh = createDeferredPromise<unknown>()
|
||||
refetchBuildDraft.mockReturnValueOnce(staleRefresh.promise)
|
||||
mocks.checkoutBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
},
|
||||
dataUpdatedAt: 1,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: refetchBuildDraft,
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
expect(refetchBuildDraft).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
staleRefresh.resolve({
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'stale refreshed prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await staleRefresh.promise
|
||||
})
|
||||
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('prompt:build prompt')
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should discard the build draft when restarting build mode with a build draft', async () => {
|
||||
@@ -631,9 +1069,6 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
})
|
||||
@@ -693,6 +1128,7 @@ describe('AgentConfigurePage', () => {
|
||||
it('should apply the build draft and rebase the composer store from the refetched normal draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const refetchComposer = vi.fn(async () => {
|
||||
mocks.queryState.composer = {
|
||||
...mocks.queryState.composer,
|
||||
@@ -756,13 +1192,16 @@ describe('AgentConfigurePage', () => {
|
||||
},
|
||||
expect.any(Object),
|
||||
))
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agent'],
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agents'],
|
||||
})
|
||||
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(refetchComposer).toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
@@ -842,9 +1281,6 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(refetchComposer).toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
@@ -900,9 +1336,6 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
})
|
||||
@@ -954,9 +1387,6 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentConfigureClearPreviewChatAtom,
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
agentConfigureRightPanelChatModeAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureSelectVersionAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
rebaseAgentConfigureComposerAtom,
|
||||
resetAgentConfigureConversationAtom,
|
||||
setAgentConfigureConversationIdAtom,
|
||||
} from '../state'
|
||||
|
||||
describe('agent configure state graph', () => {
|
||||
it('selects versions through the shared source override entrypoint', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(agentConfigureSelectVersionAtom, 'snapshot-1')
|
||||
|
||||
expect(store.get(agentConfigureSelectedVersionIdAtom)).toBe('snapshot-1')
|
||||
expect(store.get(agentConfigureSoulSourceOverrideAtom)).toBe('view-version')
|
||||
|
||||
store.set(agentConfigureSelectVersionAtom, null)
|
||||
|
||||
expect(store.get(agentConfigureSelectedVersionIdAtom)).toBeNull()
|
||||
expect(store.get(agentConfigureSoulSourceOverrideAtom)).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the actual chat mode from the visible right panel mode', () => {
|
||||
const store = createStore()
|
||||
|
||||
expect(store.get(agentConfigureRightPanelChatModeAtom)).toBe('build')
|
||||
|
||||
store.set(agentConfigureRightPanelModeAtom, 'preview')
|
||||
|
||||
expect(store.get(agentConfigureRightPanelChatModeAtom)).toBe('build')
|
||||
})
|
||||
|
||||
it('updates and resets conversation state through named actions', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(setAgentConfigureConversationIdAtom, {
|
||||
mode: 'build',
|
||||
conversationId: 'build-conversation-1',
|
||||
})
|
||||
store.set(setAgentConfigureConversationIdAtom, {
|
||||
mode: 'preview',
|
||||
conversationId: 'preview-conversation-1',
|
||||
})
|
||||
|
||||
expect(store.get(agentConfigureConversationIdsAtom)).toEqual({
|
||||
build: 'build-conversation-1',
|
||||
preview: 'preview-conversation-1',
|
||||
})
|
||||
|
||||
store.set(resetAgentConfigureConversationAtom, 'build')
|
||||
|
||||
expect(store.get(agentConfigureConversationIdsAtom)).toEqual({
|
||||
build: null,
|
||||
preview: 'preview-conversation-1',
|
||||
})
|
||||
expect(store.get(agentConfigureClearPreviewChatAtom)).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks composer rebase as a workflow command', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(rebaseAgentConfigureComposerAtom)
|
||||
store.set(rebaseAgentConfigureComposerAtom)
|
||||
|
||||
expect(store.get(agentConfigureComposerRebaseRevisionAtom)).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps independent panel state as separate primitives', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(agentConfigureShowPreviewVersionsAtom, true)
|
||||
|
||||
expect(store.get(agentConfigureShowPreviewVersionsAtom)).toBe(true)
|
||||
})
|
||||
})
|
||||
+159
-3
@@ -9,6 +9,7 @@ import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/stor
|
||||
import { useAgentConfigureSync } from '../use-agent-configure-sync'
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -88,6 +89,9 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
get: {
|
||||
key: () => ['agents'],
|
||||
},
|
||||
byAgentId: {
|
||||
get: {
|
||||
queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [
|
||||
@@ -168,6 +172,7 @@ describe('useAgentConfigureSync', () => {
|
||||
it('should automatically save configure page changes to draft', async () => {
|
||||
vi.setSystemTime(1710000100000)
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
@@ -183,7 +188,7 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: false,
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
@@ -206,14 +211,52 @@ describe('useAgentConfigureSync', () => {
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Draft only prompt',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: false,
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agent-detail', 'agent-1'],
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBe(1710000105000)
|
||||
})
|
||||
|
||||
it('should cancel pending autosave when the draft returns to the saved baseline', async () => {
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Temporary prompt',
|
||||
})
|
||||
})
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should include Agent Soul files when autosaving file changes', async () => {
|
||||
const { store } = renderUseAgentConfigureSync()
|
||||
|
||||
@@ -321,6 +364,27 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should keep autosave failures silent and leave the local draft dirty', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Unsaved autosave prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Unsaved autosave prompt')
|
||||
expect(toastMock.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save the latest draft immediately when requested', async () => {
|
||||
vi.setSystemTime(1710000200000)
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
@@ -354,6 +418,73 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(result.current.draftSavedAt).toBe(1710000200000)
|
||||
})
|
||||
|
||||
it('should reject explicit save requests when the draft cannot be saved', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Run prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await expect(result.current.saveDraft()).rejects.toThrow('Failed to save agent composer draft.')
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Run prompt')
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
|
||||
it('should not save the draft immediately when the composer draft is unchanged', async () => {
|
||||
const { queryClient, result } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should save the effective model before run when the form draft is unchanged', async () => {
|
||||
const { result } = renderUseAgentConfigureSync({
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: '',
|
||||
},
|
||||
},
|
||||
currentModel: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
model_provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
plugin_id: 'langgenius/openai',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should reject manual save when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
@@ -502,6 +633,31 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(publishAgentMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reject publish and keep the publish mutation untouched when saving the draft fails', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Unpublished prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await expect(result.current.publishDraft()).rejects.toThrow('Failed to save agent composer draft.')
|
||||
|
||||
expect(publishAgentMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
|
||||
it('should reject publish when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppDetailWithSite, AgentIconType } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { AgentAppDetailWithSite, AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { useAgentConfigureData } from '../hooks'
|
||||
import type { AgentConfigureConversationIds, AgentConfigureRightPanelMode } from './preview/right-panel-chat'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useAgentConfigureModelOptions } from '../hooks'
|
||||
import {
|
||||
agentConfigureBuildDraftActionsDisabledAtom,
|
||||
agentConfigureClearPreviewChatAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
agentConfigureRightPanelChatModeAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
agentConfigureShowChatFeaturesAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
resetAgentConfigureConversationAtom,
|
||||
setAgentConfigureConversationIdAtom,
|
||||
} from '../state'
|
||||
import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData } from '../use-agent-configure-build-draft'
|
||||
import { useAgentConfigureSync } from '../use-agent-configure-sync'
|
||||
import { AgentOrchestratePanel } from './orchestrate'
|
||||
@@ -24,15 +37,6 @@ import { useAgentWorkingDirectoryPanel } from './preview/use-working-directory-p
|
||||
import { AgentPreviewVersionsPanel } from './preview/versions-panel'
|
||||
import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from './workspace'
|
||||
|
||||
type DebugConversationRefreshInput = {
|
||||
params: {
|
||||
agent_id: string
|
||||
}
|
||||
body: {
|
||||
debug_conversation_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentConfigureComposerScope({
|
||||
agentId,
|
||||
composerRebaseRevision,
|
||||
@@ -53,6 +57,8 @@ export function AgentConfigureComposerScope({
|
||||
activeVersionId,
|
||||
agentSoulConfig,
|
||||
} = configureData
|
||||
const soulSourceOverride = useAtomValue(agentConfigureSoulSourceOverrideAtom)
|
||||
const setSoulSourceOverride = useSetAtom(agentConfigureSoulSourceOverrideAtom)
|
||||
const isViewingVersion = !!selectedVersionId
|
||||
const buildDraft = useAgentConfigureBuildDraftData({
|
||||
agentId,
|
||||
@@ -60,6 +66,8 @@ export function AgentConfigureComposerScope({
|
||||
composerAgentSoulConfig: composerQuery.data?.agent_soul,
|
||||
isViewingVersion,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
setSoulSourceOverride,
|
||||
soulSourceOverride,
|
||||
})
|
||||
|
||||
if (buildDraft.isPending) {
|
||||
@@ -68,9 +76,7 @@ export function AgentConfigureComposerScope({
|
||||
)
|
||||
}
|
||||
|
||||
const composerSessionKey = buildDraft.isActive
|
||||
? `${agentId}:${buildDraft.activeVersionId ?? 'build-draft'}`
|
||||
: `${agentId}:${buildDraft.activeVersionId ?? 'draft'}:${composerRebaseRevision}`
|
||||
const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerRebaseRevision}`
|
||||
|
||||
return (
|
||||
<AgentConfigurePageComposerSession
|
||||
@@ -106,16 +112,7 @@ function AgentConfigurePageComposerSession({
|
||||
agentQuery,
|
||||
} = configureData
|
||||
const queryClient = useQueryClient()
|
||||
const [showChatFeatures, setShowChatFeatures] = useState(false)
|
||||
const [showPreviewVersions, setShowPreviewVersions] = useState(false)
|
||||
const workingDirectoryPanel = useAgentWorkingDirectoryPanel()
|
||||
const [clearPreviewChat, setClearPreviewChat] = useState(false)
|
||||
const [rightPanelMode, setRightPanelMode] = useState<AgentConfigureRightPanelMode>('build')
|
||||
const [hideBuildDraftBarUntilRefresh, setHideBuildDraftBarUntilRefresh] = useState(false)
|
||||
const [conversationIds, setConversationIds] = useState<AgentConfigureConversationIds>({
|
||||
build: agentQuery.data?.debug_conversation_id ?? null,
|
||||
preview: null,
|
||||
})
|
||||
const agentIconType = agentQuery.data?.icon_type as AgentIconType | null | undefined
|
||||
const refreshDebugConversationMutation = useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({
|
||||
onSuccess: ({ debug_conversation_id }) => {
|
||||
@@ -138,73 +135,48 @@ function AgentConfigurePageComposerSession({
|
||||
mutateAsync: refreshDebugConversationRequestAsync,
|
||||
isPending: isRefreshingDebugConversation,
|
||||
} = refreshDebugConversationMutation
|
||||
const refreshDebugConversationInput = useCallback((conversationId: string): DebugConversationRefreshInput => ({
|
||||
const refreshDebugConversationInput = useCallback(() => ({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: conversationId,
|
||||
},
|
||||
}), [agentId])
|
||||
const refreshDebugConversation = useCallback((conversationId: string) => {
|
||||
const input = refreshDebugConversationInput(conversationId)
|
||||
|
||||
refreshDebugConversationRequest(
|
||||
input as unknown as Parameters<typeof refreshDebugConversationRequest>[0],
|
||||
)
|
||||
const refreshDebugConversation = useCallback(() => {
|
||||
refreshDebugConversationRequest(refreshDebugConversationInput())
|
||||
}, [refreshDebugConversationInput, refreshDebugConversationRequest])
|
||||
const refreshDebugConversationAsync = useCallback((conversationId: string) => {
|
||||
const input = refreshDebugConversationInput(conversationId)
|
||||
|
||||
return refreshDebugConversationRequestAsync(
|
||||
input as unknown as Parameters<typeof refreshDebugConversationRequestAsync>[0],
|
||||
)
|
||||
const refreshDebugConversationAsync = useCallback(() => {
|
||||
return refreshDebugConversationRequestAsync(refreshDebugConversationInput())
|
||||
}, [refreshDebugConversationInput, refreshDebugConversationRequestAsync])
|
||||
const resetBuildChatSession = useCallback(async () => {
|
||||
try {
|
||||
await refreshDebugConversationAsync(conversationIds.build ?? '')
|
||||
}
|
||||
finally {
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
build: null,
|
||||
}))
|
||||
setClearPreviewChat(true)
|
||||
}
|
||||
}, [conversationIds.build, refreshDebugConversationAsync])
|
||||
|
||||
return (
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[agentConfigureConversationIdsAtom, {
|
||||
build: agentQuery.data?.debug_conversation_id ?? null,
|
||||
preview: null,
|
||||
}],
|
||||
]}
|
||||
name="AgentConfigureConversation"
|
||||
>
|
||||
<AgentConfigurePageComposerContent
|
||||
agentId={agentId}
|
||||
agentIconType={agentIconType}
|
||||
buildDraft={buildDraft}
|
||||
clearPreviewChat={clearPreviewChat}
|
||||
configureData={configureData}
|
||||
conversationIds={conversationIds}
|
||||
hideBuildDraftBarUntilRefresh={hideBuildDraftBarUntilRefresh}
|
||||
isRefreshingDebugConversation={isRefreshingDebugConversation}
|
||||
isViewingVersion={isViewingVersion}
|
||||
resetBuildChatSession={resetBuildChatSession}
|
||||
rightPanelMode={rightPanelMode}
|
||||
setClearPreviewChat={setClearPreviewChat}
|
||||
setConversationIds={setConversationIds}
|
||||
setHideBuildDraftBarUntilRefresh={setHideBuildDraftBarUntilRefresh}
|
||||
setRightPanelMode={setRightPanelMode}
|
||||
setShowChatFeatures={setShowChatFeatures}
|
||||
setShowPreviewVersions={setShowPreviewVersions}
|
||||
showChatFeatures={showChatFeatures}
|
||||
showPreviewVersions={showPreviewVersions}
|
||||
workingDirectoryPanel={workingDirectoryPanel}
|
||||
onComposerRebase={onComposerRebase}
|
||||
onRefreshDebugConversation={refreshDebugConversation}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
>
|
||||
<AgentConfigurePageComposerContent
|
||||
agentId={agentId}
|
||||
agentIconType={agentIconType}
|
||||
buildDraft={buildDraft}
|
||||
configureData={configureData}
|
||||
isRefreshingDebugConversation={isRefreshingDebugConversation}
|
||||
isViewingVersion={isViewingVersion}
|
||||
workingDirectoryPanel={workingDirectoryPanel}
|
||||
onComposerRebase={onComposerRebase}
|
||||
onRefreshDebugConversation={refreshDebugConversation}
|
||||
onRefreshDebugConversationAsync={refreshDebugConversationAsync}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,49 +184,25 @@ function AgentConfigurePageComposerContent({
|
||||
agentId,
|
||||
agentIconType,
|
||||
buildDraft,
|
||||
clearPreviewChat,
|
||||
configureData,
|
||||
conversationIds,
|
||||
hideBuildDraftBarUntilRefresh,
|
||||
isRefreshingDebugConversation,
|
||||
isViewingVersion,
|
||||
resetBuildChatSession,
|
||||
rightPanelMode,
|
||||
setClearPreviewChat,
|
||||
setConversationIds,
|
||||
setHideBuildDraftBarUntilRefresh,
|
||||
setRightPanelMode,
|
||||
setShowChatFeatures,
|
||||
setShowPreviewVersions,
|
||||
showChatFeatures,
|
||||
showPreviewVersions,
|
||||
workingDirectoryPanel,
|
||||
onComposerRebase,
|
||||
onRefreshDebugConversation,
|
||||
onRefreshDebugConversationAsync,
|
||||
onSelectVersion,
|
||||
}: {
|
||||
agentId: string
|
||||
agentIconType: AgentIconType | null | undefined
|
||||
buildDraft: ReturnType<typeof useAgentConfigureBuildDraftData>
|
||||
clearPreviewChat: boolean
|
||||
configureData: ReturnType<typeof useAgentConfigureData>
|
||||
conversationIds: AgentConfigureConversationIds
|
||||
hideBuildDraftBarUntilRefresh: boolean
|
||||
isRefreshingDebugConversation: boolean
|
||||
isViewingVersion: boolean
|
||||
resetBuildChatSession: () => Promise<void>
|
||||
rightPanelMode: AgentConfigureRightPanelMode
|
||||
setClearPreviewChat: Dispatch<SetStateAction<boolean>>
|
||||
setConversationIds: Dispatch<SetStateAction<AgentConfigureConversationIds>>
|
||||
setHideBuildDraftBarUntilRefresh: Dispatch<SetStateAction<boolean>>
|
||||
setRightPanelMode: Dispatch<SetStateAction<AgentConfigureRightPanelMode>>
|
||||
setShowChatFeatures: Dispatch<SetStateAction<boolean>>
|
||||
setShowPreviewVersions: Dispatch<SetStateAction<boolean>>
|
||||
showChatFeatures: boolean
|
||||
showPreviewVersions: boolean
|
||||
workingDirectoryPanel: ReturnType<typeof useAgentWorkingDirectoryPanel>
|
||||
onComposerRebase: () => void
|
||||
onRefreshDebugConversation: (conversationId: string) => void
|
||||
onRefreshDebugConversation: () => void
|
||||
onRefreshDebugConversationAsync: () => Promise<unknown>
|
||||
onSelectVersion: (versionId: string | null) => void
|
||||
}) {
|
||||
const {
|
||||
@@ -266,8 +214,36 @@ function AgentConfigurePageComposerContent({
|
||||
activeConfigSnapshot,
|
||||
agentSoulConfig,
|
||||
} = configureData
|
||||
const rightPanelChatMode: AgentConfigureRightPanelMode = rightPanelMode === 'preview' ? 'build' : rightPanelMode
|
||||
const showBuildDraftBar = buildDraft.isActive && !hideBuildDraftBarUntilRefresh
|
||||
const buildDraftActionsDisabled = useAtomValue(agentConfigureBuildDraftActionsDisabledAtom)
|
||||
const clearPreviewChat = useAtomValue(agentConfigureClearPreviewChatAtom)
|
||||
const conversationIds = useAtomValue(agentConfigureConversationIdsAtom)
|
||||
const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom)
|
||||
const showChatFeatures = useAtomValue(agentConfigureShowChatFeaturesAtom)
|
||||
const showPreviewVersions = useAtomValue(agentConfigureShowPreviewVersionsAtom)
|
||||
const resetConversation = useSetAtom(resetAgentConfigureConversationAtom)
|
||||
const setBuildDraftActionsDisabled = useSetAtom(agentConfigureBuildDraftActionsDisabledAtom)
|
||||
const setClearPreviewChat = useSetAtom(agentConfigureClearPreviewChatAtom)
|
||||
const setConversationId = useSetAtom(setAgentConfigureConversationIdAtom)
|
||||
const setRightPanelMode = useSetAtom(agentConfigureRightPanelModeAtom)
|
||||
const setShowChatFeatures = useSetAtom(agentConfigureShowChatFeaturesAtom)
|
||||
const setShowPreviewVersions = useSetAtom(agentConfigureShowPreviewVersionsAtom)
|
||||
const rebaseComposerDraft = useSetAtom(rebaseAgentComposerDraftAtom)
|
||||
const showBuildDraftBar = buildDraft.isActive
|
||||
const resetBuildChatSession = useCallback(async () => {
|
||||
try {
|
||||
await onRefreshDebugConversationAsync()
|
||||
}
|
||||
finally {
|
||||
setConversationId({ mode: 'build', conversationId: null })
|
||||
setClearPreviewChat(true)
|
||||
}
|
||||
}, [onRefreshDebugConversationAsync, setClearPreviewChat, setConversationId])
|
||||
const rebaseComposerDraftFromSoulConfig = useCallback((agentSoulConfig?: AgentSoulConfig) => {
|
||||
rebaseComposerDraft({
|
||||
draft: agentSoulConfigToFormState(agentSoulConfig),
|
||||
originalConfig: agentSoulConfig,
|
||||
})
|
||||
}, [rebaseComposerDraft])
|
||||
const {
|
||||
currentModel,
|
||||
setConfigureModel,
|
||||
@@ -287,6 +263,8 @@ function AgentConfigurePageComposerContent({
|
||||
const buildDraftActions = useAgentConfigureBuildDraftActions({
|
||||
agentId,
|
||||
isActive: buildDraft.isActive,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
rebaseComposerDraft: rebaseComposerDraftFromSoulConfig,
|
||||
refetchBuildDraft: buildDraft.refetch,
|
||||
refetchComposer: composerQuery.refetch,
|
||||
resetBuildChatSession,
|
||||
@@ -295,23 +273,28 @@ function AgentConfigurePageComposerContent({
|
||||
setSoulSourceOverride: buildDraft.setSoulSourceOverride,
|
||||
})
|
||||
const selectVersion = useCallback((versionId: string | null) => {
|
||||
buildDraft.setSoulSourceOverride(versionId ? 'view-version' : null)
|
||||
onSelectVersion(versionId)
|
||||
}, [buildDraft, onSelectVersion])
|
||||
}, [onSelectVersion])
|
||||
const hasRestartCurrentChatTarget = !!conversationIds[rightPanelChatMode] || (rightPanelChatMode === 'build' && buildDraft.isActive)
|
||||
const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget
|
||||
|| buildDraftActionsDisabled
|
||||
|| isRefreshingDebugConversation
|
||||
|| buildDraftActions.isApplyingBuildDraft
|
||||
|| buildDraftActions.isDiscardingBuildDraft
|
||||
const isChatFeaturesReadOnly = isViewingVersion || buildDraft.isActive
|
||||
const restartCurrentChat = () => {
|
||||
if (isRestartCurrentChatDisabled)
|
||||
return
|
||||
|
||||
if (rightPanelChatMode === 'build' && buildDraft.isActive) {
|
||||
void buildDraftActions.discardBuildDraft()
|
||||
return
|
||||
}
|
||||
|
||||
if (rightPanelChatMode === 'build')
|
||||
onRefreshDebugConversation(conversationIds.build ?? '')
|
||||
onRefreshDebugConversation()
|
||||
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
[rightPanelChatMode]: null,
|
||||
}))
|
||||
setClearPreviewChat(true)
|
||||
resetConversation(rightPanelChatMode)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -336,6 +319,7 @@ function AgentConfigurePageComposerContent({
|
||||
? (
|
||||
<AgentBuildDraftBar
|
||||
changesCount={buildDraft.changesCount}
|
||||
disabled={buildDraftActionsDisabled}
|
||||
isApplying={buildDraftActions.isApplyingBuildDraft}
|
||||
isDiscarding={buildDraftActions.isDiscardingBuildDraft}
|
||||
onApply={() => {
|
||||
@@ -371,7 +355,7 @@ function AgentConfigurePageComposerContent({
|
||||
workingDirectoryPanel.openWorkingDirectory()
|
||||
}}
|
||||
onRefresh={restartCurrentChat}
|
||||
refreshDisabled={isRefreshingDebugConversation || buildDraftActions.isDiscardingBuildDraft}
|
||||
refreshDisabled={isRestartCurrentChatDisabled}
|
||||
/>
|
||||
)}
|
||||
chat={(
|
||||
@@ -389,20 +373,27 @@ function AgentConfigurePageComposerContent({
|
||||
onClearChatListChange={setClearPreviewChat}
|
||||
onConversationComplete={(mode) => {
|
||||
if (mode === 'build')
|
||||
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setHideBuildDraftBarUntilRefresh(false))
|
||||
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setBuildDraftActionsDisabled(false))
|
||||
}}
|
||||
onConversationIdChange={(mode, conversationId) => {
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
[mode]: conversationId,
|
||||
}))
|
||||
setConversationId({ mode, conversationId })
|
||||
}}
|
||||
onSaveDraftBeforeRun={rightPanelChatMode === 'build'
|
||||
? async () => {
|
||||
setHideBuildDraftBarUntilRefresh(true)
|
||||
await buildDraftActions.prepareBuildDraftBeforeRun()
|
||||
setBuildDraftActionsDisabled(true)
|
||||
try {
|
||||
return await buildDraftActions.prepareBuildDraftBeforeRun()
|
||||
}
|
||||
catch (error) {
|
||||
setBuildDraftActionsDisabled(false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
: saveDraft}
|
||||
onSendInterrupted={() => {
|
||||
if (rightPanelChatMode === 'build')
|
||||
setBuildDraftActionsDisabled(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -420,8 +411,8 @@ function AgentConfigurePageComposerContent({
|
||||
{workingDirectoryPanel.panel}
|
||||
<AgentChatFeaturesPanel
|
||||
show={showChatFeatures}
|
||||
appFeatures={agentSoulConfig?.app_features}
|
||||
disabled={versionQuery.isPending}
|
||||
appFeatures={buildDraft.agentSoulConfig?.app_features}
|
||||
disabled={versionQuery.isPending || isChatFeaturesReadOnly}
|
||||
onClose={() => setShowChatFeatures(false)}
|
||||
/>
|
||||
</>
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AgentBuildDraftBar } from '../build-draft-bar'
|
||||
|
||||
describe('AgentBuildDraftBar', () => {
|
||||
it('should disable both build draft actions when the bar is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const onDiscard = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
disabled
|
||||
onApply={onApply}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
)
|
||||
|
||||
const applyButton = screen.getByRole('button', { name: 'custom.apply' })
|
||||
const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })
|
||||
|
||||
expect(applyButton).toBeDisabled()
|
||||
expect(discardButton).toBeDisabled()
|
||||
|
||||
await user.click(applyButton)
|
||||
await user.click(discardButton)
|
||||
|
||||
expect(onApply).not.toHaveBeenCalled()
|
||||
expect(onDiscard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep discard disabled while apply is pending', () => {
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
isApplying
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'custom.apply' })).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should keep apply disabled while discard is pending', () => {
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
isDiscarding
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'custom.apply' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
+29
-4
@@ -346,17 +346,17 @@ describe('AgentConfigurePublishBar', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep published state from active config status even when local draft differs', () => {
|
||||
it('should show unpublished state from local draft changes even when active config is published', () => {
|
||||
renderPublishBar({
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot: null,
|
||||
prompt: 'Updated system prompt',
|
||||
})
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled()
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument()
|
||||
expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual(
|
||||
expect.objectContaining({ enabled: false, ignoreInputs: false }),
|
||||
expect.objectContaining({ enabled: true, ignoreInputs: false }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -437,6 +437,31 @@ describe('AgentConfigurePublishBar', () => {
|
||||
expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trust backend published state after autosave confirms the draft matches the active snapshot', () => {
|
||||
const stalePublishedDraftBaseline = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Old unpublished normal draft',
|
||||
}
|
||||
const savedDraftMatchingActiveSnapshot = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Published prompt',
|
||||
}
|
||||
|
||||
renderPublishBar({
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot,
|
||||
setupStore: (store) => {
|
||||
store.set(agentComposerPublishedDraftAtom, stalePublishedDraftBaseline)
|
||||
store.set(agentComposerOriginalDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
store.set(agentComposerDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render publishing as a single disabled action state', () => {
|
||||
renderPublishBar({ isPublishing: true, prompt: 'Updated system prompt' })
|
||||
|
||||
|
||||
+6
-3
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
type AgentBuildDraftBarProps = {
|
||||
changesCount: number
|
||||
disabled?: boolean
|
||||
isApplying?: boolean
|
||||
isDiscarding?: boolean
|
||||
onApply: () => void
|
||||
@@ -13,6 +14,7 @@ type AgentBuildDraftBarProps = {
|
||||
|
||||
export function AgentBuildDraftBar({
|
||||
changesCount,
|
||||
disabled = false,
|
||||
isApplying = false,
|
||||
isDiscarding = false,
|
||||
onApply,
|
||||
@@ -20,10 +22,11 @@ export function AgentBuildDraftBar({
|
||||
}: AgentBuildDraftBarProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCustom } = useTranslation('custom')
|
||||
const isPending = isApplying || isDiscarding
|
||||
const metaLabel = changesCount > 0
|
||||
? t('agentDetail.configure.buildDraft.changes', { count: changesCount })
|
||||
: t('agentDetail.configure.buildDraft.noChanges')
|
||||
const applyDisabled = disabled || isDiscarding
|
||||
const discardDisabled = disabled || isApplying
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-full min-w-0 items-center gap-2 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-2 pr-2 pl-4 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
@@ -39,7 +42,7 @@ export function AgentBuildDraftBar({
|
||||
type="button"
|
||||
variant="primary"
|
||||
loading={isApplying}
|
||||
disabled={isPending}
|
||||
disabled={applyDisabled}
|
||||
className="h-8 rounded-lg px-3"
|
||||
onClick={onApply}
|
||||
>
|
||||
@@ -49,7 +52,7 @@ export function AgentBuildDraftBar({
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={isDiscarding}
|
||||
disabled={isPending}
|
||||
disabled={discardDisabled}
|
||||
className="h-8 rounded-lg px-3"
|
||||
onClick={onDiscard}
|
||||
>
|
||||
|
||||
+14
-6
@@ -13,7 +13,7 @@ import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation'
|
||||
import { hasAgentComposerUnpublishedChangesAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { hasAgentComposerUnpublishedChangesAtom, isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
@@ -43,21 +43,26 @@ type AgentConfigurePublishBarProps = {
|
||||
function getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
isDirty,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing,
|
||||
}: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
isDirty: boolean
|
||||
hasLocalChanges: boolean
|
||||
hasUnpublishedChanges: boolean
|
||||
isPublishing: boolean
|
||||
}): AgentConfigurePublishState {
|
||||
if (isPublishing)
|
||||
return 'publishing'
|
||||
|
||||
if (hasLocalChanges)
|
||||
return 'unpublished'
|
||||
|
||||
if (activeConfigIsPublished)
|
||||
return 'published'
|
||||
|
||||
if (isDirty)
|
||||
if (hasUnpublishedChanges)
|
||||
return 'unpublished'
|
||||
|
||||
if (!activeConfigSnapshot)
|
||||
@@ -97,19 +102,22 @@ export function AgentConfigurePublishBar({
|
||||
const queryClient = useQueryClient()
|
||||
const [publishBarMode, setPublishBarMode] = useState<PublishBarMode>({ status: 'compact' })
|
||||
const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom)
|
||||
const hasLocalChanges = useAtomValue(isAgentComposerDirtyAtom)
|
||||
const knowledgeRetrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
|
||||
const knowledgeValidation = validateKnowledgeRetrievals(knowledgeRetrievals)
|
||||
const getValidationMessage = useKnowledgeValidationMessage()
|
||||
const publishableState = getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
isDirty: hasUnpublishedChanges,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing: false,
|
||||
})
|
||||
const publishState = getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
isDirty: hasUnpublishedChanges,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing,
|
||||
})
|
||||
const publishIsAvailable = !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished')
|
||||
|
||||
+264
-18
@@ -1,7 +1,8 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
@@ -13,27 +14,43 @@ const handleSendMock = vi.hoisted(() => vi.fn())
|
||||
const stopCallbackRef = vi.hoisted(() => ({
|
||||
current: undefined as undefined | ((taskId: string) => void),
|
||||
}))
|
||||
const sendResultRef = vi.hoisted(() => ({
|
||||
current: undefined as unknown,
|
||||
}))
|
||||
const chatMessagesGetMock = vi.hoisted(() => vi.fn())
|
||||
const suggestedQuestionsGetMock = vi.hoisted(() => vi.fn())
|
||||
const stopPostMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => function MockChat(props: {
|
||||
onSend: (message: string) => void
|
||||
onStopResponding: () => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => props.onSend('hello')}>
|
||||
send
|
||||
</button>
|
||||
<button type="button" onClick={props.onStopResponding}>
|
||||
stop
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
default: () => function MockChat(props: {
|
||||
onSend: (message: string) => unknown
|
||||
onStopResponding: () => void
|
||||
}) {
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSent(true)
|
||||
sendResultRef.current = props.onSend('hello')
|
||||
}}
|
||||
>
|
||||
send
|
||||
</button>
|
||||
<button type="button" onClick={props.onStopResponding}>
|
||||
stop
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
|
||||
useChat: useChatMock.mockImplementation((
|
||||
@@ -136,6 +153,93 @@ function renderPreviewChat(props?: Partial<ComponentProps<typeof AgentChatRuntim
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeConversationHarness() {
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<AgentChatRuntime
|
||||
agentId="agent-1"
|
||||
clearChatList={false}
|
||||
conversationId={conversationId}
|
||||
inputPlaceholder="Message agent"
|
||||
renderEmptyState={() => null}
|
||||
onClearChatListChange={vi.fn()}
|
||||
onConversationIdChange={setConversationId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeClearCommandHarness({
|
||||
inputPlaceholder,
|
||||
}: {
|
||||
inputPlaceholder: string
|
||||
}) {
|
||||
const [clearChatList, setClearChatList] = useState(true)
|
||||
|
||||
return (
|
||||
<AgentChatRuntime
|
||||
agentId="agent-1"
|
||||
clearChatList={clearChatList}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
renderEmptyState={() => null}
|
||||
onClearChatListChange={setClearChatList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewChatWithConversationHarness() {
|
||||
const store = createStore()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
store.set(agentComposerModelAtom, {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
store.set(agentComposerPromptAtom, 'You are helpful.')
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<RuntimeConversationHarness />
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewChatWithClearCommandHarness() {
|
||||
const store = createStore()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
store.set(agentComposerModelAtom, {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
store.set(agentComposerPromptAtom, 'You are helpful.')
|
||||
|
||||
const renderHarness = (inputPlaceholder: string) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<RuntimeClearCommandHarness inputPlaceholder={inputPlaceholder} />
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return {
|
||||
...render(renderHarness('Message agent')),
|
||||
renderHarness,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentPreviewChat', () => {
|
||||
beforeEach(() => {
|
||||
useChatMock.mockClear()
|
||||
@@ -144,6 +248,7 @@ describe('AgentPreviewChat', () => {
|
||||
suggestedQuestionsGetMock.mockResolvedValue({ data: [] })
|
||||
stopPostMock.mockResolvedValue({ result: 'success' })
|
||||
stopCallbackRef.current = undefined
|
||||
sendResultRef.current = undefined
|
||||
})
|
||||
|
||||
it('should initialize preview chat with the stable debug conversation history', async () => {
|
||||
@@ -258,6 +363,60 @@ describe('AgentPreviewChat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify the owner when a send settles with an error', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
act(() => {
|
||||
callbacks.onSendSettled(true)
|
||||
})
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify the owner when stopping a responding send', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop' }))
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
expect(stopPostMock).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
task_id: 'task-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify the owner once when a stopped send later settles with an error', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop' }))
|
||||
act(() => {
|
||||
callbacks.onSendSettled(true)
|
||||
})
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not send preview chat when draft save fails', async () => {
|
||||
const saveDraftBeforeRun = vi.fn().mockRejectedValue(new Error('save failed'))
|
||||
renderPreviewChat({
|
||||
@@ -267,6 +426,7 @@ describe('AgentPreviewChat', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(saveDraftBeforeRun).toHaveBeenCalledTimes(1))
|
||||
await expect(sendResultRef.current).resolves.toBe(false)
|
||||
expect(handleSendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -287,6 +447,92 @@ describe('AgentPreviewChat', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should send build chat inputs from the prepared build draft snapshot', async () => {
|
||||
const saveDraftBeforeRun = vi.fn().mockResolvedValue({
|
||||
app_variables: [
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text-input',
|
||||
default: 'Paris',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
model: {
|
||||
model_provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
},
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
})
|
||||
renderPreviewChat({
|
||||
agentSoulConfig: {
|
||||
app_variables: [
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text-input',
|
||||
default: 'London',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
draftType: 'debug_build',
|
||||
onSaveDraftBeforeRun: saveDraftBeforeRun,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
expect(handleSendMock).toHaveBeenCalledWith(
|
||||
'agent/agent-1/chat-messages',
|
||||
expect.objectContaining({
|
||||
draft_type: 'debug_build',
|
||||
inputs: {
|
||||
city: 'Paris',
|
||||
},
|
||||
overrideInputsForm: [
|
||||
expect.objectContaining({
|
||||
variable: 'city',
|
||||
default: 'Paris',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep the current chat session visible when a sent message creates a conversation', async () => {
|
||||
chatMessagesGetMock.mockReturnValue(new Promise(() => undefined))
|
||||
renderPreviewChatWithConversationHarness()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
await act(async () => {
|
||||
callbacks.onConversationComplete('conversation-created-by-send')
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'send' })).toBeInTheDocument()
|
||||
expect(screen.getByText('sessionSent:yes')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the reset command acknowledgement stable while clear chat is pending', async () => {
|
||||
const { renderHarness, rerender } = renderPreviewChatWithClearCommandHarness()
|
||||
|
||||
await waitFor(() => expect(useChatMock).toHaveBeenCalled())
|
||||
const firstResetAcknowledgement = useChatMock.mock.calls.at(-1)?.[5]
|
||||
|
||||
rerender(renderHarness('Message agent again'))
|
||||
|
||||
await waitFor(() => expect(useChatMock.mock.calls.length).toBeGreaterThan(1))
|
||||
const secondResetAcknowledgement = useChatMock.mock.calls.at(-1)?.[5]
|
||||
|
||||
expect(secondResetAcknowledgement).toBe(firstResetAcknowledgement)
|
||||
})
|
||||
|
||||
it('should keep preview file upload disabled by default', async () => {
|
||||
renderPreviewChat()
|
||||
|
||||
|
||||
+13
@@ -9,6 +9,7 @@ function renderHeader({
|
||||
onToggleChatFeatures = vi.fn(),
|
||||
onOpenWorkingDirectory = vi.fn(),
|
||||
onRefresh = vi.fn(),
|
||||
refreshDisabled = false,
|
||||
}: {
|
||||
mode?: 'build' | 'preview'
|
||||
previewEnabled?: boolean
|
||||
@@ -16,6 +17,7 @@ function renderHeader({
|
||||
onToggleChatFeatures?: () => void
|
||||
onOpenWorkingDirectory?: () => void
|
||||
onRefresh?: () => void
|
||||
refreshDisabled?: boolean
|
||||
} = {}) {
|
||||
render(
|
||||
<AgentPreviewHeader
|
||||
@@ -26,6 +28,7 @@ function renderHeader({
|
||||
onToggleChatFeatures={onToggleChatFeatures}
|
||||
onOpenWorkingDirectory={onOpenWorkingDirectory}
|
||||
onRefresh={onRefresh}
|
||||
refreshDisabled={refreshDisabled}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
@@ -45,6 +48,16 @@ describe('AgentPreviewHeader', () => {
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not emit refresh when the restart button is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRefresh = vi.fn()
|
||||
renderHeader({ mode: 'build', onRefresh, refreshDisabled: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' }))
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show chat features in build mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onToggleChatFeatures = vi.fn()
|
||||
|
||||
+4
-1
@@ -80,12 +80,15 @@ function AgentChatFeaturesPanelContent({
|
||||
const featuresStore = useFeaturesStore()
|
||||
const setAppFeatures = useSetAppFeatures()
|
||||
const handleChange = useCallback(() => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
const features = featuresStore?.getState().features
|
||||
if (!features)
|
||||
return
|
||||
|
||||
setAppFeatures(currentAppFeatures => toAppFeatures(features, currentAppFeatures ?? appFeatures))
|
||||
}, [appFeatures, featuresStore, setAppFeatures])
|
||||
}, [appFeatures, disabled, featuresStore, setAppFeatures])
|
||||
|
||||
return (
|
||||
<NewFeaturePanel
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import type {
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import type { FeedbackType, IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { FeedbackType, IChatItem, InputForm, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
@@ -23,7 +23,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area'
|
||||
import { useChat } from '@/app/components/base/chat/chat/hooks'
|
||||
import { buildChatItemTree, getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
|
||||
@@ -155,6 +155,15 @@ const toInputForm = (variable: NonNullable<AgentSoulConfig['app_variables']>[num
|
||||
}
|
||||
}
|
||||
|
||||
const getAgentSoulInputsForm = (agentSoulConfig?: AgentSoulConfig) => (agentSoulConfig?.app_variables ?? []).map(toInputForm)
|
||||
|
||||
const getAgentSoulInputs = (inputsForm: InputForm[]) => {
|
||||
return inputsForm.reduce<Inputs>((acc, input) => {
|
||||
acc[input.variable] = (input.default ?? '') as Inputs[string]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const toAgentTool = (tool: AgentSoulDifyToolConfig) => ({
|
||||
provider_id: tool.provider_id ?? tool.provider ?? tool.plugin_id ?? '',
|
||||
provider_type: tool.provider_type ?? 'builtin',
|
||||
@@ -445,7 +454,8 @@ export type AgentChatRuntimeProps = {
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}
|
||||
|
||||
export function AgentChatRuntime({
|
||||
@@ -464,29 +474,40 @@ export function AgentChatRuntime({
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: AgentChatRuntimeProps) {
|
||||
const [currentSessionConversationId, setCurrentSessionConversationId] = useState<string | null>(null)
|
||||
const handleClearChatListChange = useCallback((nextClearChatList: boolean) => {
|
||||
if (!nextClearChatList)
|
||||
setCurrentSessionConversationId(null)
|
||||
onClearChatListChange(nextClearChatList)
|
||||
}, [onClearChatListChange])
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['agent-chat-conversation-messages', agentId, conversationId],
|
||||
queryFn: () => fetchAgentConversationMessages(agentId, conversationId!),
|
||||
enabled: !!conversationId,
|
||||
})
|
||||
const conversationBelongsToCurrentSession = !!conversationId && conversationId === currentSessionConversationId
|
||||
const initialChatTree = useMemo(
|
||||
() => getFormattedAgentDebugChatTree(historyQuery.data?.data ?? []),
|
||||
[historyQuery.data?.data],
|
||||
)
|
||||
|
||||
if (conversationId && historyQuery.isPending) {
|
||||
if (conversationId && historyQuery.isPending && !conversationBelongsToCurrentSession) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chatSessionKey = !conversationId || conversationBelongsToCurrentSession
|
||||
? 'current-session'
|
||||
: `${conversationId}-${historyQuery.dataUpdatedAt}`
|
||||
|
||||
return (
|
||||
<AgentPreviewChatSession
|
||||
key={`${conversationId ?? 'new'}-${historyQuery.dataUpdatedAt}`}
|
||||
key={chatSessionKey}
|
||||
agentId={agentId}
|
||||
agentIcon={agentIcon}
|
||||
agentIconBackground={agentIconBackground}
|
||||
@@ -500,9 +521,11 @@ export function AgentChatRuntime({
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
sendButtonLabel={sendButtonLabel}
|
||||
renderEmptyState={renderEmptyState}
|
||||
onClearChatListChange={onClearChatListChange}
|
||||
onClearChatListChange={handleClearChatListChange}
|
||||
onConversationComplete={onConversationComplete}
|
||||
onConversationIdChange={onConversationIdChange}
|
||||
onCurrentSessionConversationIdChange={setCurrentSessionConversationId}
|
||||
onSendInterrupted={onSendInterrupted}
|
||||
onSaveDraftBeforeRun={onSaveDraftBeforeRun}
|
||||
/>
|
||||
)
|
||||
@@ -525,6 +548,8 @@ function AgentPreviewChatSession({
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: {
|
||||
agentId: string
|
||||
@@ -543,7 +568,9 @@ function AgentPreviewChatSession({
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
onCurrentSessionConversationIdChange: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}) {
|
||||
const { userProfile } = useAppContext()
|
||||
const prompt = useAtomValue(agentComposerPromptAtom)
|
||||
@@ -553,13 +580,16 @@ function AgentPreviewChatSession({
|
||||
currentModel,
|
||||
prompt,
|
||||
}), [agentSoulConfig, currentModel, prompt])
|
||||
const inputsForm = useMemo(() => (agentSoulConfig?.app_variables ?? []).map(toInputForm), [agentSoulConfig?.app_variables])
|
||||
const inputs = useMemo(() => {
|
||||
return inputsForm.reduce<Inputs>((acc, input) => {
|
||||
acc[input.variable] = (input.default ?? '') as Inputs[string]
|
||||
return acc
|
||||
}, {})
|
||||
}, [inputsForm])
|
||||
const inputsForm = useMemo(() => getAgentSoulInputsForm(agentSoulConfig), [agentSoulConfig])
|
||||
const inputs = useMemo(() => getAgentSoulInputs(inputsForm), [inputsForm])
|
||||
const sendInterruptedRef = useRef(false)
|
||||
const notifySendInterrupted = useCallback(() => {
|
||||
if (sendInterruptedRef.current)
|
||||
return
|
||||
|
||||
sendInterruptedRef.current = true
|
||||
onSendInterrupted?.()
|
||||
}, [onSendInterrupted])
|
||||
const {
|
||||
textGenerationModelList,
|
||||
} = useTextGenerationCurrentProviderAndModelAndModelList(currentModel)
|
||||
@@ -589,40 +619,64 @@ function AgentPreviewChatSession({
|
||||
)
|
||||
|
||||
const doSend: OnSend = useCallback(async (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
sendInterruptedRef.current = false
|
||||
|
||||
try {
|
||||
await onSaveDraftBeforeRun?.()
|
||||
const preparedAgentSoulConfig = await onSaveDraftBeforeRun?.()
|
||||
const runtimeAgentSoulConfig = preparedAgentSoulConfig || agentSoulConfig
|
||||
const runtimeInputsForm = preparedAgentSoulConfig ? getAgentSoulInputsForm(runtimeAgentSoulConfig) : inputsForm
|
||||
const runtimeInputs = preparedAgentSoulConfig ? getAgentSoulInputs(runtimeInputsForm) : inputs
|
||||
const runtimeConfig = preparedAgentSoulConfig
|
||||
? buildChatConfig({
|
||||
agentSoulConfig: runtimeAgentSoulConfig,
|
||||
currentModel: undefined,
|
||||
prompt: runtimeAgentSoulConfig?.prompt?.system_prompt ?? '',
|
||||
})
|
||||
: config
|
||||
|
||||
const currentProvider = textGenerationModelList.find(item => item.provider === runtimeConfig.model.provider)
|
||||
const selectedModel = currentProvider?.models.find(model => model.model === runtimeConfig.model.name)
|
||||
const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
const data: Record<string, unknown> = {
|
||||
query: message,
|
||||
inputs: runtimeInputs,
|
||||
overrideInputsForm: runtimeInputsForm,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
if (draftType)
|
||||
data.draft_type = draftType
|
||||
|
||||
if (files?.length && supportVision)
|
||||
data.files = files
|
||||
|
||||
handleSend(
|
||||
`agent/${agentId}/chat-messages`,
|
||||
data as Parameters<typeof handleSend>[1],
|
||||
{
|
||||
onGetConversationMessages: conversationId => fetchAgentConversationMessages(agentId, conversationId),
|
||||
onGetSuggestedQuestions: responseItemId => fetchAgentSuggestedQuestions(agentId, responseItemId),
|
||||
onConversationComplete: (completedConversationId) => {
|
||||
if (completedConversationId && completedConversationId !== conversationId)
|
||||
onCurrentSessionConversationIdChange(completedConversationId)
|
||||
onConversationIdChange?.(completedConversationId)
|
||||
onConversationComplete?.(completedConversationId)
|
||||
},
|
||||
onSendSettled: (hasError) => {
|
||||
if (hasError)
|
||||
notifySendInterrupted()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
return false
|
||||
}
|
||||
}, [agentId, agentSoulConfig, chatList, config, conversationId, draftType, handleSend, inputs, inputsForm, notifySendInterrupted, onConversationComplete, onConversationIdChange, onCurrentSessionConversationIdChange, onSaveDraftBeforeRun, textGenerationModelList])
|
||||
|
||||
const currentProvider = textGenerationModelList.find(item => item.provider === config.model.provider)
|
||||
const selectedModel = currentProvider?.models.find(model => model.model === config.model.name)
|
||||
const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
const data: Record<string, unknown> = {
|
||||
query: message,
|
||||
inputs,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
if (draftType)
|
||||
data.draft_type = draftType
|
||||
|
||||
if (files?.length && supportVision)
|
||||
data.files = files
|
||||
|
||||
handleSend(
|
||||
`agent/${agentId}/chat-messages`,
|
||||
data as Parameters<typeof handleSend>[1],
|
||||
{
|
||||
onGetConversationMessages: conversationId => fetchAgentConversationMessages(agentId, conversationId),
|
||||
onGetSuggestedQuestions: responseItemId => fetchAgentSuggestedQuestions(agentId, responseItemId),
|
||||
onConversationComplete: (conversationId) => {
|
||||
onConversationIdChange?.(conversationId)
|
||||
onConversationComplete?.(conversationId)
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [agentId, chatList, config.model.name, config.model.provider, draftType, handleSend, inputs, onConversationComplete, onConversationIdChange, onSaveDraftBeforeRun, textGenerationModelList])
|
||||
const doStopResponding = useCallback(() => {
|
||||
handleStop()
|
||||
notifySendInterrupted()
|
||||
}, [handleStop, notifySendInterrupted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)
|
||||
@@ -685,7 +739,7 @@ function AgentPreviewChatSession({
|
||||
inputsForm={inputsForm}
|
||||
onRegenerate={doRegenerate}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
onStopResponding={handleStop}
|
||||
onStopResponding={doStopResponding}
|
||||
noChatInput={isEmptyChat}
|
||||
showPromptLog
|
||||
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
|
||||
|
||||
+1
-3
@@ -1,13 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigureConversationIds, AgentConfigureRightPanelMode } from '../../state'
|
||||
import { useAgentPreviewSoulConfig } from '../../hooks'
|
||||
import { AgentBuildChat } from './build-chat'
|
||||
import { AgentPreviewChat } from './preview-chat'
|
||||
|
||||
export type AgentConfigureRightPanelMode = 'build' | 'preview'
|
||||
export type AgentConfigureConversationIds = Record<AgentConfigureRightPanelMode, string | null>
|
||||
|
||||
export function AgentConfigureRightPanelChat({
|
||||
agentSoulConfig,
|
||||
conversationIds,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentConfigureComposerScope } from './components/composer-session'
|
||||
import { AgentConfigurePageLoading } from './components/page-loading'
|
||||
import { useAgentConfigureData } from './hooks'
|
||||
import {
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureScopedAtoms,
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureSelectVersionAtom,
|
||||
rebaseAgentConfigureComposerAtom,
|
||||
} from './state'
|
||||
|
||||
type AgentConfigurePageProps = {
|
||||
agentId: string
|
||||
@@ -14,7 +22,13 @@ export function AgentConfigurePage({
|
||||
agentId,
|
||||
}: AgentConfigurePageProps) {
|
||||
return (
|
||||
<AgentConfigurePageContent agentId={agentId} />
|
||||
<ScopeProvider
|
||||
key={agentId}
|
||||
atoms={agentConfigureScopedAtoms}
|
||||
name="AgentConfigure"
|
||||
>
|
||||
<AgentConfigurePageContent agentId={agentId} />
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,8 +36,10 @@ function AgentConfigurePageContent({
|
||||
agentId,
|
||||
}: AgentConfigurePageProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null)
|
||||
const [composerRebaseRevision, setComposerRebaseRevision] = useState(0)
|
||||
const selectedVersionId = useAtomValue(agentConfigureSelectedVersionIdAtom)
|
||||
const composerRebaseRevision = useAtomValue(agentConfigureComposerRebaseRevisionAtom)
|
||||
const rebaseComposer = useSetAtom(rebaseAgentConfigureComposerAtom)
|
||||
const selectVersion = useSetAtom(agentConfigureSelectVersionAtom)
|
||||
const configureData = useAgentConfigureData(agentId, selectedVersionId)
|
||||
const isConfigureDataPending = configureData.agentQuery.isPending
|
||||
|| configureData.composerQuery.isPending
|
||||
@@ -40,8 +56,8 @@ function AgentConfigurePageContent({
|
||||
agentId={agentId}
|
||||
composerRebaseRevision={composerRebaseRevision}
|
||||
configureData={configureData}
|
||||
onComposerRebase={() => setComposerRebaseRevision(revision => revision + 1)}
|
||||
onSelectVersion={setSelectedVersionId}
|
||||
onComposerRebase={rebaseComposer}
|
||||
onSelectVersion={selectVersion}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export type AgentConfigureRightPanelMode = 'build' | 'preview'
|
||||
export type AgentConfigureConversationIds = Record<AgentConfigureRightPanelMode, string | null>
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
export const agentConfigureSelectedVersionIdAtom = atom<string | null>(null)
|
||||
export const agentConfigureComposerRebaseRevisionAtom = atom(0)
|
||||
export const agentConfigureSoulSourceOverrideAtom = atom<AgentConfigureSoulSource | null>(null)
|
||||
|
||||
export const agentConfigureShowChatFeaturesAtom = atom(false)
|
||||
export const agentConfigureShowPreviewVersionsAtom = atom(false)
|
||||
export const agentConfigureClearPreviewChatAtom = atom(false)
|
||||
export const agentConfigureRightPanelModeAtom = atom<AgentConfigureRightPanelMode>('build')
|
||||
export const agentConfigureBuildDraftActionsDisabledAtom = atom(false)
|
||||
export const agentConfigureConversationIdsAtom = atom<AgentConfigureConversationIds>({
|
||||
build: null,
|
||||
preview: null,
|
||||
})
|
||||
|
||||
export const agentConfigureRightPanelChatModeAtom = atom((get): AgentConfigureRightPanelMode => {
|
||||
const mode = get(agentConfigureRightPanelModeAtom)
|
||||
|
||||
return mode === 'preview' ? 'build' : mode
|
||||
})
|
||||
|
||||
export const agentConfigureSelectVersionAtom = atom(null, (_get, set, versionId: string | null) => {
|
||||
set(agentConfigureSoulSourceOverrideAtom, versionId ? 'view-version' : null)
|
||||
set(agentConfigureSelectedVersionIdAtom, versionId)
|
||||
})
|
||||
|
||||
export const rebaseAgentConfigureComposerAtom = atom(null, (get, set) => {
|
||||
set(agentConfigureComposerRebaseRevisionAtom, get(agentConfigureComposerRebaseRevisionAtom) + 1)
|
||||
})
|
||||
|
||||
export const setAgentConfigureConversationIdAtom = atom(null, (get, set, {
|
||||
mode,
|
||||
conversationId,
|
||||
}: {
|
||||
mode: AgentConfigureRightPanelMode
|
||||
conversationId: string | null
|
||||
}) => {
|
||||
set(agentConfigureConversationIdsAtom, {
|
||||
...get(agentConfigureConversationIdsAtom),
|
||||
[mode]: conversationId,
|
||||
})
|
||||
})
|
||||
|
||||
export const resetAgentConfigureConversationAtom = atom(null, (get, set, mode: AgentConfigureRightPanelMode) => {
|
||||
set(agentConfigureConversationIdsAtom, {
|
||||
...get(agentConfigureConversationIdsAtom),
|
||||
[mode]: null,
|
||||
})
|
||||
set(agentConfigureClearPreviewChatAtom, true)
|
||||
})
|
||||
|
||||
export const agentConfigureScopedAtoms = [
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
agentConfigureShowChatFeaturesAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureClearPreviewChatAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
agentConfigureBuildDraftActionsDisabledAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
] as const
|
||||
@@ -1,19 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigureSoulSource } from './state'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
export function usePrepareAgentBuildDraftBeforeRun({
|
||||
agentId,
|
||||
isBuildDraftActive,
|
||||
rebaseComposerDraft,
|
||||
saveDraft,
|
||||
setSoulSourceOverride,
|
||||
}: {
|
||||
agentId?: string
|
||||
isBuildDraftActive: boolean
|
||||
rebaseComposerDraft?: (agentSoulConfig?: AgentSoulConfig) => void
|
||||
saveDraft: () => Promise<unknown>
|
||||
setSoulSourceOverride?: (source: AgentConfigureSoulSource) => void
|
||||
}) {
|
||||
@@ -44,8 +46,10 @@ export function usePrepareAgentBuildDraftBeforeRun({
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(buildDraftQueryOptions.queryKey, buildDraft)
|
||||
rebaseComposerDraft?.(buildDraft.agent_soul as AgentSoulConfig | undefined)
|
||||
setSoulSourceOverride?.('build-draft')
|
||||
}, [agentId, buildDraftQueryOptions.queryKey, checkoutBuildDraft, isBuildDraftActive, queryClient, saveDraft, setSoulSourceOverride])
|
||||
return buildDraft.agent_soul as AgentSoulConfig | undefined
|
||||
}, [agentId, buildDraftQueryOptions.queryKey, checkoutBuildDraft, isBuildDraftActive, queryClient, rebaseComposerDraft, saveDraft, setSoulSourceOverride])
|
||||
|
||||
return {
|
||||
isCheckingOutBuildDraft,
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigureSoulSource } from './state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { agentConfigureConsoleQuery } from './build-draft-query'
|
||||
import { usePrepareAgentBuildDraftBeforeRun } from './use-agent-build-draft-run'
|
||||
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404
|
||||
const getAgentSoulConfigFromRefetchResult = (result: unknown) => {
|
||||
return (result as { data?: { agent_soul?: AgentSoulConfig } } | undefined)?.data?.agent_soul
|
||||
}
|
||||
|
||||
export function useAgentConfigureBuildDraftData({
|
||||
agentId,
|
||||
@@ -20,15 +22,18 @@ export function useAgentConfigureBuildDraftData({
|
||||
composerAgentSoulConfig,
|
||||
isViewingVersion,
|
||||
normalAgentSoulConfig,
|
||||
setSoulSourceOverride,
|
||||
soulSourceOverride,
|
||||
}: {
|
||||
agentId: string
|
||||
activeVersionId: string | null | undefined
|
||||
composerAgentSoulConfig?: AgentSoulConfig
|
||||
isViewingVersion: boolean
|
||||
normalAgentSoulConfig?: AgentSoulConfig
|
||||
setSoulSourceOverride: (source: AgentConfigureSoulSource | null) => void
|
||||
soulSourceOverride: AgentConfigureSoulSource | null
|
||||
}) {
|
||||
const shouldSilenceBuildDraftCheckRef = useRef(true)
|
||||
const [soulSourceOverride, setSoulSourceOverride] = useState<AgentConfigureSoulSource | null>(null)
|
||||
const buildDraftQueryInput = {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
@@ -68,6 +73,8 @@ export function useAgentConfigureBuildDraftData({
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const {
|
||||
data: buildDraftData,
|
||||
@@ -111,6 +118,8 @@ export function useAgentConfigureBuildDraftData({
|
||||
export function useAgentConfigureBuildDraftActions({
|
||||
agentId,
|
||||
isActive,
|
||||
normalAgentSoulConfig,
|
||||
rebaseComposerDraft,
|
||||
refetchBuildDraft,
|
||||
refetchComposer,
|
||||
resetBuildChatSession,
|
||||
@@ -120,6 +129,8 @@ export function useAgentConfigureBuildDraftActions({
|
||||
}: {
|
||||
agentId: string
|
||||
isActive: boolean
|
||||
normalAgentSoulConfig?: AgentSoulConfig
|
||||
rebaseComposerDraft: (agentSoulConfig?: AgentSoulConfig) => void
|
||||
refetchBuildDraft: () => Promise<unknown>
|
||||
refetchComposer: () => Promise<unknown>
|
||||
resetBuildChatSession: () => Promise<void>
|
||||
@@ -130,6 +141,7 @@ export function useAgentConfigureBuildDraftActions({
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const buildDraftRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const buildDraftRefreshGenerationRef = useRef(0)
|
||||
const buildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
@@ -137,6 +149,7 @@ export function useAgentConfigureBuildDraftActions({
|
||||
},
|
||||
},
|
||||
})
|
||||
const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } })
|
||||
const applyBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.apply.post.mutationOptions())
|
||||
const discardBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions())
|
||||
const { mutateAsync: applyBuildDraftRequest, isPending: isApplyingBuildDraft } = applyBuildDraftMutation
|
||||
@@ -144,32 +157,64 @@ export function useAgentConfigureBuildDraftActions({
|
||||
const { prepareBuildDraftBeforeRun } = usePrepareAgentBuildDraftBeforeRun({
|
||||
agentId,
|
||||
isBuildDraftActive: isActive,
|
||||
rebaseComposerDraft,
|
||||
saveDraft,
|
||||
setSoulSourceOverride,
|
||||
})
|
||||
|
||||
const cancelBuildDraftRefresh = useCallback(() => {
|
||||
buildDraftRefreshGenerationRef.current += 1
|
||||
if (!buildDraftRefreshTimerRef.current)
|
||||
return
|
||||
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
buildDraftRefreshTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const prepareBuildDraftRun = useCallback(async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
return prepareBuildDraftBeforeRun()
|
||||
}, [cancelBuildDraftRefresh, prepareBuildDraftBeforeRun])
|
||||
|
||||
const refreshBuildDraftAfterBuildChat = useCallback((onRefreshed?: () => void) => {
|
||||
if (buildDraftRefreshTimerRef.current)
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
cancelBuildDraftRefresh()
|
||||
const refreshGeneration = buildDraftRefreshGenerationRef.current
|
||||
|
||||
buildDraftRefreshTimerRef.current = setTimeout(async () => {
|
||||
buildDraftRefreshTimerRef.current = null
|
||||
await refetchBuildDraft()
|
||||
onRefreshed?.()
|
||||
try {
|
||||
const result = await refetchBuildDraft()
|
||||
if (refreshGeneration !== buildDraftRefreshGenerationRef.current)
|
||||
return
|
||||
|
||||
const agentSoulConfig = getAgentSoulConfigFromRefetchResult(result)
|
||||
if (agentSoulConfig)
|
||||
rebaseComposerDraft(agentSoulConfig)
|
||||
}
|
||||
catch {}
|
||||
finally {
|
||||
if (refreshGeneration === buildDraftRefreshGenerationRef.current)
|
||||
onRefreshed?.()
|
||||
}
|
||||
}, 1000)
|
||||
}, [refetchBuildDraft])
|
||||
}, [cancelBuildDraftRefresh, rebaseComposerDraft, refetchBuildDraft])
|
||||
|
||||
const exitBuildDraftMode = useCallback(async (shouldRefetchComposer: boolean) => {
|
||||
cancelBuildDraftRefresh()
|
||||
await resetBuildChatSession().catch(() => undefined)
|
||||
setSoulSourceOverride('draft')
|
||||
queryClient.removeQueries({
|
||||
queryKey: buildDraftQueryOptions.queryKey,
|
||||
})
|
||||
if (shouldRefetchComposer) {
|
||||
await refetchComposer()
|
||||
const result = await refetchComposer()
|
||||
rebaseComposerDraft(getAgentSoulConfigFromRefetchResult(result) ?? normalAgentSoulConfig)
|
||||
onComposerRebased?.()
|
||||
}
|
||||
}, [buildDraftQueryOptions.queryKey, onComposerRebased, queryClient, refetchComposer, resetBuildChatSession, setSoulSourceOverride])
|
||||
else {
|
||||
rebaseComposerDraft(normalAgentSoulConfig)
|
||||
}
|
||||
}, [buildDraftQueryOptions.queryKey, cancelBuildDraftRefresh, normalAgentSoulConfig, onComposerRebased, queryClient, rebaseComposerDraft, refetchComposer, resetBuildChatSession, setSoulSourceOverride])
|
||||
|
||||
const applyBuildDraft = async () => {
|
||||
try {
|
||||
@@ -178,6 +223,12 @@ export function useAgentConfigureBuildDraftActions({
|
||||
agent_id: agentId,
|
||||
},
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: agentDetailQueryKey,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
await exitBuildDraftMode(true)
|
||||
toast.success(tCommon('api.actionSuccess'))
|
||||
}
|
||||
@@ -203,17 +254,17 @@ export function useAgentConfigureBuildDraftActions({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (buildDraftRefreshTimerRef.current)
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
cancelBuildDraftRefresh()
|
||||
}
|
||||
}, [])
|
||||
}, [cancelBuildDraftRefresh])
|
||||
|
||||
return {
|
||||
applyBuildDraft,
|
||||
cancelBuildDraftRefresh,
|
||||
discardBuildDraft,
|
||||
isApplyingBuildDraft,
|
||||
isDiscardingBuildDraft,
|
||||
prepareBuildDraftBeforeRun,
|
||||
prepareBuildDraftBeforeRun: prepareBuildDraftRun,
|
||||
refreshBuildDraftAfterBuildChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppDetailWithSite, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { useSetAtom, useStore } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -64,21 +65,6 @@ export function useAgentConfigureSync({
|
||||
currentModel: currentModelRef.current,
|
||||
}), [store])
|
||||
|
||||
const markActiveConfigUnpublished = useCallback(() => {
|
||||
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
|
||||
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
(agentDetail) => {
|
||||
if (!agentDetail)
|
||||
return agentDetail
|
||||
|
||||
return {
|
||||
...agentDetail,
|
||||
active_config_is_published: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
}, [agentId, queryClient])
|
||||
|
||||
const {
|
||||
mutateAsync: saveComposerDraft,
|
||||
} = useMutation(
|
||||
@@ -94,13 +80,16 @@ export function useAgentConfigureSync({
|
||||
const saveComposer = useSerialAsyncCallback(async ({
|
||||
configSnapshot,
|
||||
draftBaseline,
|
||||
silent = true,
|
||||
}: {
|
||||
configSnapshot: AgentSoulConfig
|
||||
draftBaseline: AgentSoulConfigFormState
|
||||
silent?: boolean
|
||||
}) => {
|
||||
const savedDraftKey = JSON.stringify(configSnapshot)
|
||||
const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } })
|
||||
try {
|
||||
await saveComposerDraft({
|
||||
const composerState = await saveComposerDraft({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
@@ -110,13 +99,24 @@ export function useAgentConfigureSync({
|
||||
agent_soul: configSnapshot,
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.agent.byAgentId.composer.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
composerState,
|
||||
)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: agentDetailQueryKey,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Draft sync follows workflow autosave behavior: save failures are silent and keep the local draft intact.
|
||||
// Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary.
|
||||
if (!silent) {
|
||||
toast.error(tCommon('api.actionFailed'))
|
||||
throw new Error('Failed to save agent composer draft.')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
markActiveConfigUnpublished()
|
||||
setOriginalDraft(draftBaseline)
|
||||
setDraftSavedAt(Date.now())
|
||||
lastAutosavedDraftKeyRef.current = savedDraftKey
|
||||
@@ -146,11 +146,16 @@ export function useAgentConfigureSync({
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid)
|
||||
throw new InvalidKnowledgeConfigurationError()
|
||||
|
||||
const configSnapshot = getAgentSoulDraft()
|
||||
const hasEffectiveModelChange = !isEqual(configSnapshot.model, baseConfigRef.current?.model)
|
||||
debouncedSaveDraft.cancel?.()
|
||||
if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange)
|
||||
return
|
||||
|
||||
await saveComposer({
|
||||
configSnapshot: getAgentSoulDraft(),
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
silent: false,
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
|
||||
|
||||
@@ -164,11 +169,11 @@ export function useAgentConfigureSync({
|
||||
!enabledRef.current
|
||||
|| !isDirty
|
||||
) {
|
||||
if (!isDirty)
|
||||
debouncedSaveDraft.cancel?.()
|
||||
return
|
||||
}
|
||||
|
||||
markActiveConfigUnpublished()
|
||||
|
||||
if (
|
||||
!validateKnowledgeRetrievals(store.get(agentComposerDraftAtom).knowledgeRetrievals).isValid
|
||||
|| lastAutosavedDraftKeyRef.current === agentSoulDraftKey
|
||||
@@ -178,7 +183,7 @@ export function useAgentConfigureSync({
|
||||
|
||||
debouncedSaveDraft()
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, markActiveConfigUnpublished, store])
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, store])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -206,6 +211,7 @@ export function useAgentConfigureSync({
|
||||
const saved = await saveComposer({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
silent: false,
|
||||
})
|
||||
if (!saved)
|
||||
return
|
||||
|
||||
+132
-30
@@ -1,5 +1,19 @@
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { handleStream } from './base'
|
||||
import { handleStream, sseGet, ssePost } from './base'
|
||||
|
||||
const refreshAccessTokenOrReLoginMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./refresh-token', () => ({
|
||||
refreshAccessTokenOrReLogin: refreshAccessTokenOrReLoginMock,
|
||||
}))
|
||||
|
||||
describe('handleStream', () => {
|
||||
beforeEach(() => {
|
||||
@@ -8,11 +22,9 @@ describe('handleStream', () => {
|
||||
|
||||
describe('Invalid response data handling', () => {
|
||||
it('should handle null bufferObj from JSON.parse gracefully', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
// Create a mock response that returns 'data: null'
|
||||
const mockReader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@@ -32,13 +44,10 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert
|
||||
expect(onData).toHaveBeenCalledWith('', true, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
@@ -49,11 +58,9 @@ describe('handleStream', () => {
|
||||
})
|
||||
|
||||
it('should handle non-object bufferObj from JSON.parse gracefully', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
// Create a mock response that returns a primitive value
|
||||
const mockReader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@@ -73,13 +80,10 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert
|
||||
expect(onData).toHaveBeenCalledWith('', true, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
@@ -90,7 +94,6 @@ describe('handleStream', () => {
|
||||
})
|
||||
|
||||
it('should handle valid message event correctly', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
@@ -121,13 +124,10 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert
|
||||
expect(onData).toHaveBeenCalledWith('Hello world', true, {
|
||||
conversationId: 'conv-123',
|
||||
taskId: 'task-456',
|
||||
@@ -137,7 +137,6 @@ describe('handleStream', () => {
|
||||
})
|
||||
|
||||
it('should handle error status 400 correctly', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
@@ -166,13 +165,10 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert
|
||||
expect(onData).toHaveBeenCalledWith('', false, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
@@ -183,7 +179,6 @@ describe('handleStream', () => {
|
||||
})
|
||||
|
||||
it('should handle malformed JSON gracefully', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
@@ -206,19 +201,15 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert - malformed JSON triggers the catch block which calls onData and returns
|
||||
expect(onData).toHaveBeenCalled()
|
||||
expect(onCompleted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should dispatch reasoning_chunk events to onReasoning', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const onReasoning = vi.fn()
|
||||
@@ -248,10 +239,8 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// onReasoning is the last positional handler; fill the unused intervening slots.
|
||||
const interveningNoops = Array.from({ length: 29 }, () => undefined)
|
||||
|
||||
// Act
|
||||
;(handleStream as (...args: unknown[]) => void)(
|
||||
mockResponse,
|
||||
onData,
|
||||
@@ -260,23 +249,136 @@ describe('handleStream', () => {
|
||||
onReasoning,
|
||||
)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert - the full event object is forwarded to onReasoning, answer stays untouched
|
||||
expect(onReasoning).toHaveBeenCalledWith(reasoningEvent)
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should complete with error when the stream reader rejects', async () => {
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
const mockReader = {
|
||||
read: vi.fn().mockRejectedValueOnce(new Error('stream lost')),
|
||||
}
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onData).toHaveBeenCalledWith('', false, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
errorMessage: 'Error: stream lost',
|
||||
errorCode: 'stream_read_error',
|
||||
})
|
||||
})
|
||||
expect(onCompleted).toHaveBeenCalledWith(true, 'Error: stream lost')
|
||||
})
|
||||
|
||||
it('should throw error when response is not ok', () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
} as unknown as Response
|
||||
|
||||
// Act & Assert
|
||||
expect(() => handleStream(mockResponse, onData)).toThrow('Network response was not ok')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssePost and sseGet', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should report fetch failures through onError without throwing from the catch handler', async () => {
|
||||
const onError = vi.fn()
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new TypeError('Network failed'))
|
||||
|
||||
await ssePost('/chat-messages', {
|
||||
body: {
|
||||
query: 'hello',
|
||||
},
|
||||
}, {
|
||||
onError,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('TypeError: Network failed')
|
||||
})
|
||||
expect(toast.error).toHaveBeenCalledWith('TypeError: Network failed')
|
||||
})
|
||||
|
||||
it('should report token refresh failures through onError', async () => {
|
||||
const onError = vi.fn()
|
||||
refreshAccessTokenOrReLoginMock.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
|
||||
await ssePost('/chat-messages', {
|
||||
body: {
|
||||
query: 'hello',
|
||||
},
|
||||
}, {
|
||||
onError,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('Error: refresh failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should report event stream token refresh failures through onError', async () => {
|
||||
const onError = vi.fn()
|
||||
refreshAccessTokenOrReLoginMock.mockRejectedValueOnce(new Error('resume refresh failed'))
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
|
||||
await sseGet('/workflow/workflow-run-1/events', {}, {
|
||||
onError,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('Error: resume refresh failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should report stream reader failures through onError and onCompleted', async () => {
|
||||
const onError = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const mockReader = {
|
||||
read: vi.fn().mockRejectedValueOnce(new Error('stream lost')),
|
||||
}
|
||||
const response = {
|
||||
status: 200,
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response)
|
||||
|
||||
await ssePost('/chat-messages', {
|
||||
body: {
|
||||
query: 'hello',
|
||||
},
|
||||
}, {
|
||||
onError,
|
||||
onCompleted,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('Error: stream lost', 'stream_read_error')
|
||||
})
|
||||
expect(onCompleted).toHaveBeenCalledWith(true, 'Error: stream lost')
|
||||
expect(toast.error).toHaveBeenCalledWith('Error: stream lost')
|
||||
})
|
||||
})
|
||||
|
||||
+24
-6
@@ -236,6 +236,16 @@ export const handleStream = (
|
||||
let buffer = ''
|
||||
let bufferObj: Record<string, any>
|
||||
let isFirstMessage = true
|
||||
const completeWithError = (errorMessage: string, errorCode?: string) => {
|
||||
onData('', false, {
|
||||
conversationId: bufferObj?.conversation_id,
|
||||
messageId: bufferObj?.message_id ?? '',
|
||||
errorMessage,
|
||||
errorCode,
|
||||
})
|
||||
onCompleted?.(true, errorMessage)
|
||||
}
|
||||
|
||||
function read() {
|
||||
let hasError = false
|
||||
reader?.read().then((result: ReadableStreamReadResult<Uint8Array>) => {
|
||||
@@ -399,6 +409,8 @@ export const handleStream = (
|
||||
}
|
||||
if (!hasError)
|
||||
read()
|
||||
}, (e: unknown) => {
|
||||
completeWithError(String(e), 'stream_read_error')
|
||||
})
|
||||
}
|
||||
read()
|
||||
@@ -553,7 +565,9 @@ export const ssePost = async (
|
||||
refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
|
||||
ssePost(url, fetchOptions, otherOptions)
|
||||
}).catch((err) => {
|
||||
const errorMessage = String(err)
|
||||
console.error(err)
|
||||
onError?.(errorMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -611,9 +625,10 @@ export const ssePost = async (
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property'))
|
||||
toast.error(String(e))
|
||||
onError?.(e)
|
||||
const errorMessage = String(e)
|
||||
if (errorMessage !== 'AbortError: The user aborted a request.' && !errorMessage.includes('TypeError: Cannot assign to read only property'))
|
||||
toast.error(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -703,7 +718,9 @@ export const sseGet = async (
|
||||
refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
|
||||
sseGet(url, fetchOptions, otherOptions)
|
||||
}).catch((err) => {
|
||||
const errorMessage = String(err)
|
||||
console.error(err)
|
||||
onError?.(errorMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -761,9 +778,10 @@ export const sseGet = async (
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().includes('TypeError: Cannot assign to read only property'))
|
||||
toast.error(String(e))
|
||||
onError?.(e)
|
||||
const errorMessage = String(e)
|
||||
if (errorMessage !== 'AbortError: The user aborted a request.' && !errorMessage.includes('TypeError: Cannot assign to read only property'))
|
||||
toast.error(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -519,7 +519,7 @@ describe('consoleQuery agent mutation defaults', () => {
|
||||
expect(queryClient.getQueryData(inviteOptionsQueryKey)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should keep roster and invite option lists stable after saving an agent draft', async () => {
|
||||
it('should invalidate roster list but keep invite options stable after saving an agent draft', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
@@ -543,7 +543,7 @@ describe('consoleQuery agent mutation defaults', () => {
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
|
||||
@@ -520,12 +520,12 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
|
||||
put: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_composerState, variables, _onMutateResult, context) => {
|
||||
if (variables.body.save_strategy !== 'save_as_new_version')
|
||||
return
|
||||
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
if (variables.body.save_strategy !== 'save_as_new_version')
|
||||
return
|
||||
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user