Compare commits

...
Author SHA1 Message Date
yyh 0b9e03c2c1 fix(agent-v2): preserve interrupted build input 2026-06-28 03:49:25 +08:00
autofix-ci[bot]andGitHub b34eb8f24b [autofix.ci] apply automated fixes 2026-06-27 19:42:43 +00:00
yyh 625cfad888 fix(agent-v2): fence stale build draft refresh 2026-06-28 03:36:34 +08:00
yyh 3859367e3a fix(agent-v2): cancel inline build refresh timer 2026-06-28 03:21:09 +08:00
yyh 92c0974e19 fix(agent-v2): fail explicit draft save commands 2026-06-28 03:14:51 +08:00
yyh f7d859afac fix(agent-v2): guard build draft refresh lifecycle 2026-06-28 03:12:34 +08:00
yyh b522f7df40 fix(agent-v2): align chat feature draft source 2026-06-28 03:07:16 +08:00
autofix-ci[bot]andGitHub 1787145f2a [autofix.ci] apply automated fixes 2026-06-27 19:00:10 +00:00
yyh f581dfc016 docs(agent-v2): align configure module boundary 2026-06-28 02:54:09 +08:00
yyh 26291a0f42 test(agent-v2): tighten state graph review coverage 2026-06-28 02:49:03 +08:00
yyh 80b9f5e083 fix(agent-v2): align inline build draft source 2026-06-28 01:55:05 +08:00
yyh bbebeec3dc fix(chat): settle stream read failures 2026-06-28 01:43:30 +08:00
yyh 6a69ec2a8a fix(agent-v2): honor debug refresh contract 2026-06-28 01:33:46 +08:00
autofix-ci[bot]andGitHub f874a6a019 [autofix.ci] apply automated fixes 2026-06-27 17:31:32 +00:00
yyh bcdd52a27b fix(agent-v2): reconcile publish state owners 2026-06-28 01:24:56 +08:00
autofix-ci[bot]andGitHub 32e9de8ae3 [autofix.ci] apply automated fixes 2026-06-27 17:17:33 +00:00
yyh 35c9e3e3d3 fix(agent-v2): close build draft state gaps 2026-06-28 01:12:04 +08:00
autofix-ci[bot]andGitHub d37783c895 [autofix.ci] apply automated fixes 2026-06-27 16:55:18 +00:00
yyh cceeabcb46 fix(agent-v2): align build draft lifecycle owners 2026-06-28 00:50:01 +08:00
yyh 8536c232ba fix(agent-v2): stabilize build chat run state 2026-06-28 00:35:16 +08:00
yyh 8383571c53 fix(agent-v2): preserve publish state for reverted drafts 2026-06-28 00:35:00 +08:00
yyh 953b42ac67 fix(agent-v2): clear publish action after reverted draft 2026-06-28 00:10:39 +08:00
yyh 4e069c1f23 fix(agent-v2): save effective model before build run 2026-06-28 00:07:08 +08:00
yyh 09b5d63c39 refactor: merge inline agent common translations 2026-06-28 00:05:05 +08:00
yyh 8f2cd4e5b0 fix(agent-v2): disable restart when chat reset is unavailable 2026-06-28 00:01:51 +08:00
yyh b57e5da995 fix(agent-v2): stabilize chat reset command 2026-06-27 23:52:51 +08:00
yyh daf13834a6 fix(agent-v2): stabilize build chat state 2026-06-27 23:47:19 +08:00
yyh 309777f8fc fix(agent-v2): skip unchanged draft save before build 2026-06-27 23:19:49 +08:00
yyh 184670f49d fix(agent-v2): clarify build draft action states 2026-06-27 23:10:58 +08:00
yyh 63aa8af5c1 refactor(agent-v2): scope configure conversation state 2026-06-27 23:09:31 +08:00
yyh ee30e59871 fix(agent-v2): keep build chat visible after conversation starts 2026-06-27 23:01:06 +08:00
yyh 0199589c3b refactor(agent-v2): clarify configure state graph 2026-06-27 22:57:36 +08:00
37 changed files with 3262 additions and 543 deletions
+64 -3
View File
@@ -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()
+4 -3
View File
@@ -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)
}
}
}
+98 -68
View File
@@ -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,
}
}
@@ -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')
})
})
})
@@ -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)
})
})
@@ -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)}
/>
</>
@@ -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')
})
})
@@ -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' })
@@ -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}
>
@@ -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')
@@ -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()
@@ -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()
@@ -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,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
View File
@@ -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
View File
@@ -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)
})
}
+2 -2
View File
@@ -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({
+3 -3
View File
@@ -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(),
})