mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
fix: finish native Agent queries and preserve discussion state
Keep Kimi read-only queries in manual approval mode rather than forcing a planning cycle, and prefer its terminal rejection over feedback-free revision. Preserve MiMo's same-message tail after the terminal RPC, forward supported native configuration variables, cancel repetitive native output while retaining partial content, and preserve each group's selected composer mode. Validation: frontend 370 tests; desktop 1655 tests; web and desktop builds; desktop pack and packaged signature verification. Audited all 13 installed CLIs. A real five-Agent unlimited discussion completed 14 native calls through an isolated Magpie null-tool-call fix. Five CLIs still have native configuration failures; production Magpie remains unchanged. Electron UI acceptance awaits macOS unlock; this commit does not certify production acceptance or publish a release.
This commit is contained in:
1 parent
4c522c0997
commit
bd395b7a2f
21 files changed
+428
-21
No files matched your search
@@ -8,6 +8,12 @@ function sha256(value) {
|
||||
// Tool kinds an Agent may use without approval during a read-only turn.
|
||||
const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
|
||||
|
||||
function terminalPlanRejection(options = []) {
|
||||
// Kimi's first reject option is "Revise", which loops without feedback.
|
||||
return options.find(option => option?.optionId === 'plan_reject_and_exit'
|
||||
&& ['reject_once', 'reject_always'].includes(option.kind))
|
||||
}
|
||||
|
||||
// Automatic answer to an Agent's permission request when the run is not
|
||||
// waiting for a person. Returns the response to send, `null` when the request
|
||||
// is refused but the Agent offered nothing to refuse with, or `undefined`
|
||||
@@ -19,7 +25,8 @@ const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
|
||||
function automaticPermissionResponse(request, { allowWrite }) {
|
||||
const options = Array.isArray(request?.options) ? request.options : []
|
||||
if (!allowWrite && !READ_ONLY_SAFE_TOOL_KINDS.has(request?.toolCall?.kind)) {
|
||||
const rejection = options.find(candidate => candidate?.kind === 'reject_once')
|
||||
const rejection = terminalPlanRejection(options)
|
||||
|| options.find(candidate => candidate?.kind === 'reject_once')
|
||||
|| options.find(candidate => candidate?.kind === 'reject_always')
|
||||
return rejection?.optionId ? { status: 'rejected', optionId: rejection.optionId } : null
|
||||
}
|
||||
@@ -69,6 +76,7 @@ function assertResumedPermissionBinding(resumed, currentBinding, sessionRef) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
terminalPlanRejection,
|
||||
READ_ONLY_SAFE_TOOL_KINDS,
|
||||
assertResumedPermissionBinding,
|
||||
automaticPermissionResponse,
|
||||
|
||||
@@ -23,12 +23,16 @@ const {
|
||||
} = require('./cli-process-support.cjs')
|
||||
const { createAcpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
|
||||
const { redactSecrets } = require('../../security/secret-redaction.cjs')
|
||||
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
|
||||
const { terminalPlanRejection } = require('../agent-permission-policy.cjs')
|
||||
|
||||
const ACP_CANCEL_GRACE_MS = 250
|
||||
// After a stop or timeout the Agent gets this long to answer the cancelled
|
||||
// prompt. A clean cancellation keeps the long-lived process and its native
|
||||
// session; only an Agent that ignores cancel is torn down.
|
||||
const ACP_CANCEL_SETTLE_MS = 3000
|
||||
// MiMo can flush the last delta of a message just after its prompt response.
|
||||
const MIMO_TERMINAL_DRAIN_MS = 50
|
||||
const ACP_INITIALIZE_TIMEOUT_MS = 60 * 1000
|
||||
const ACP_STDERR_TAIL_BYTES = 64 * 1024
|
||||
// Process identity must not change when only volatile shell values change.
|
||||
@@ -355,7 +359,7 @@ function boundedAcpStream(
|
||||
}
|
||||
|
||||
function permissionRejection(options) {
|
||||
return (options || []).find(option => (
|
||||
return terminalPlanRejection(options || []) || (options || []).find(option => (
|
||||
['reject_once', 'reject_always'].includes(option.kind)
|
||||
|| /reject|deny/i.test(`${option.optionId || ''} ${option.name || ''}`)
|
||||
))
|
||||
@@ -621,7 +625,7 @@ function runtimeSignature(agent, workdir, spec, profile, childEnv) {
|
||||
workdir,
|
||||
command: spec.command,
|
||||
args: spec.args,
|
||||
// The session mode (read-only plan versus write) is applied on every turn
|
||||
// The native session mode is applied on every turn
|
||||
// with session/set_mode, so it does not identify a process. Anything that
|
||||
// really needs a separate process (a permission policy in the environment)
|
||||
// is already part of the environment below.
|
||||
@@ -746,6 +750,10 @@ async function createAcpRuntime(input) {
|
||||
const client = {
|
||||
async requestPermission(params) {
|
||||
const turn = runtime.activeTurn
|
||||
if (runtime.agent.kind === 'mimo' && runtime.replyState.promptTerminalSequence != null) {
|
||||
runtime.replyState.permissionPendingAtPromptTerminal = true
|
||||
return { outcome: { outcome: 'cancelled' } }
|
||||
}
|
||||
const pending = permissionOutcome(params, turn?.options || {}, childEnv)
|
||||
turn?.pendingPermissionCallbacks.add(pending)
|
||||
pending.then(
|
||||
@@ -762,7 +770,18 @@ async function createAcpRuntime(input) {
|
||||
if (params.sessionId !== runtime.protocolSessionRef) return
|
||||
if (runtime.replyState.promptTerminalSequence != null
|
||||
&& Number.isInteger(sequence)
|
||||
&& sequence > runtime.replyState.promptTerminalSequence) return
|
||||
&& sequence > runtime.replyState.promptTerminalSequence
|
||||
&& !(runtime.agent.kind === 'mimo'
|
||||
&& params.update?.sessionUpdate === 'agent_message_chunk'
|
||||
&& turn.lastMessageId
|
||||
&& params.update.messageId === turn.lastMessageId)) return
|
||||
if (params.update?.sessionUpdate === 'agent_message_chunk' && params.update.messageId) {
|
||||
turn.lastMessageId = params.update.messageId
|
||||
}
|
||||
if (turn.repetitionGuard?.(params.update)) {
|
||||
turn.stopRepeatedOutput()
|
||||
return
|
||||
}
|
||||
turn.structuredOutput.ingest?.(params)
|
||||
for (const event of turn.profile.mapEvent(params.update, turn.runtimeState)) {
|
||||
turn.runtimeEvents.emit(event)
|
||||
@@ -878,6 +897,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
noteActivity,
|
||||
outboundPayloadFailed: false,
|
||||
pendingPermissionCallbacks: new Set(),
|
||||
repetitionGuard: ['opencode', 'mimo', 'hermes'].includes(runtime.agent.kind) ? createOutputRepetitionGuard() : null,
|
||||
}
|
||||
runtime.activeTurn = turn
|
||||
runtime.stderr = []
|
||||
@@ -905,7 +925,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
let cancelPromise = Promise.resolve()
|
||||
let rejectAbort
|
||||
const abortPromise = new Promise((_, reject) => { rejectAbort = reject })
|
||||
const abort = () => {
|
||||
const abort = (error = agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')) => {
|
||||
if (abortRequested) return
|
||||
abortRequested = true
|
||||
if (protocolSessionRef) {
|
||||
@@ -915,7 +935,12 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
})).catch(() => {})
|
||||
} catch { /* the ACP stream has already closed */ }
|
||||
}
|
||||
rejectAbort(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
rejectAbort(error instanceof Error ? error : agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
}
|
||||
turn.stopRepeatedOutput = () => {
|
||||
runtime.replyState.collecting = false
|
||||
abort(agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT',
|
||||
'The Agent repeated the same response without tool progress; the native turn was cancelled.'))
|
||||
}
|
||||
const operation = promise => Promise.race([promise, abortPromise, runtime.failurePromise])
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -970,6 +995,9 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
}))
|
||||
promptRequest.catch(() => {})
|
||||
promptResult = await operation(promptRequest)
|
||||
if (runtime.agent.kind === 'mimo' && turn.lastMessageId) {
|
||||
await operation(new Promise(resolve => setTimeout(resolve, MIMO_TERMINAL_DRAIN_MS)))
|
||||
}
|
||||
} finally {
|
||||
runtime.replyState.collecting = false
|
||||
}
|
||||
@@ -1038,7 +1066,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
ACP_CANCEL_SETTLE_MS,
|
||||
)
|
||||
}
|
||||
if (caughtError && promptSettled && !runtime.closed && !runtime.failureError) {
|
||||
if (caughtError && caughtError.code !== 'LOCAL_AGENT_REPEATED_OUTPUT'
|
||||
&& promptSettled && !runtime.closed && !runtime.failureError) {
|
||||
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
|
||||
value: true, enumerable: false, configurable: true,
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
shutdownAcpSessionRuntime,
|
||||
} = require('./cli-acp-runner.cjs')
|
||||
const { withOpenClawGateway } = require('./cli-openclaw-gateway.cjs')
|
||||
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
|
||||
const { imageAttachmentLimit, invocation } = require('./cli-invocations.cjs')
|
||||
const {
|
||||
connectorLimitedRuntimeEvent,
|
||||
@@ -226,6 +227,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
}))
|
||||
}
|
||||
const childEnv = childEnvironment(agent, workdir, options, platform)
|
||||
const repetitionGuard = ['opencode', 'mimo', 'hermes'].includes(agent.kind)
|
||||
? createOutputRepetitionGuard() : null
|
||||
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, childEnv)
|
||||
const capabilityWarning = connectorLimitedRuntimeEvent(agent.kind, profile)
|
||||
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
|
||||
@@ -296,6 +299,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
const progressEvent = profile.mapProgress(event)
|
||||
if (progressEvent) emitProgress(progressEvent)
|
||||
for (const runtimeEvent of profile.mapEvent(event, runtimeState)) {
|
||||
if (stopRequested || settled) return
|
||||
if (repetitionGuard?.(runtimeEvent)) {
|
||||
inputError = agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT')
|
||||
abort()
|
||||
return
|
||||
}
|
||||
runtimeEvents.emit(runtimeEvent)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -197,7 +197,9 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['acp'],
|
||||
acpMode: 'plan',
|
||||
// Native plan mode requires a plan/approval cycle even for a query.
|
||||
// Manual mode keeps writes behind the ACP permission callback.
|
||||
acpMode: 'default',
|
||||
eventTransport: 'acp',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Detect a runaway native turn, rather than deduplicating its visible answer.
|
||||
// Only consecutive long repetitions count; real tool activity resets the window.
|
||||
function createOutputRepetitionGuard() {
|
||||
let text = ''
|
||||
let unchecked = 0
|
||||
return update => {
|
||||
const type = update?.sessionUpdate || update?.type
|
||||
if (['tool_call', 'tool_call_update', 'tool_start', 'tool_result_summary'].includes(type)) {
|
||||
text = ''
|
||||
unchecked = 0
|
||||
return false
|
||||
}
|
||||
const value = type === 'answer_delta' ? update.delta
|
||||
: type === 'agent_message_chunk' && update.content?.type === 'text' ? update.content.text : ''
|
||||
const chunk = String(value || '').replace(/\s/g, '')
|
||||
if (!chunk) return false
|
||||
text = (text + chunk).slice(-8192)
|
||||
unchecked += chunk.length
|
||||
if (unchecked < 32 || text.length < 64) return false
|
||||
unchecked = 0
|
||||
if (new Set(text).size < 6) return false
|
||||
const anchor = text.slice(-8)
|
||||
let previous = text.lastIndexOf(anchor, text.length - 16)
|
||||
while (previous >= 0) {
|
||||
const period = text.length - 8 - previous
|
||||
if (period > 2048) break
|
||||
const copies = period < 80 ? 8 : 4
|
||||
if (period * copies <= text.length) {
|
||||
const block = text.slice(-period)
|
||||
if (new Set(block).size >= 6 && text.endsWith(block.repeat(copies))) return true
|
||||
}
|
||||
previous = previous > 0 ? text.lastIndexOf(anchor, previous - 1) : -1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createOutputRepetitionGuard }
|
||||
@@ -26,7 +26,7 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
|
||||
'OPENCLAW_CONFIG_PATH',
|
||||
'OPENCLAW_WORKSPACE_DIR',
|
||||
])
|
||||
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
|
||||
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEX_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|MIMOCODE_|OPENCODE_|QWEN_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
|
||||
const BLOCKED_RUNTIME_ENV = /^(?:NODE_OPTIONS|NODE_PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_|ELECTRON_RUN_AS_NODE)$/
|
||||
|
||||
function agentExecutionError(code, diagnostic = '') {
|
||||
|
||||
@@ -63,6 +63,7 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
hermes: [
|
||||
'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY',
|
||||
'OPENAI_BASE_URL', 'OPENAI_MODEL', 'HERMES_INFERENCE_PROVIDER', 'HERMES_INFERENCE_MODEL',
|
||||
'HERMES_HOME',
|
||||
],
|
||||
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
|
||||
workbuddy: [
|
||||
@@ -78,9 +79,12 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
kimi: [
|
||||
'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY',
|
||||
'KIMI_MODEL_BASE_URL', 'KIMI_MODEL_NAME',
|
||||
'KIMI_MODEL_PROVIDER_TYPE', 'KIMI_MODEL_CAPABILITIES',
|
||||
'KIMI_MODEL_MAX_CONTEXT_SIZE', 'KIMI_MODEL_MAX_OUTPUT_SIZE',
|
||||
'KIMI_CODE_HOME',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
],
|
||||
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL'],
|
||||
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL', 'MIMOCODE_CONFIG_DIR', 'MIMOCODE_CONFIG_CONTENT'],
|
||||
claude: [
|
||||
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'CLAUDE_CONFIG_DIR',
|
||||
@@ -98,11 +102,12 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
'AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_CLIENT_CERTIFICATE_PASSWORD', 'AZURE_FEDERATED_TOKEN_FILE',
|
||||
'AZURE_CONFIG_DIR',
|
||||
],
|
||||
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
|
||||
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', ...GOOGLE_CLOUD_ENV_KEYS],
|
||||
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'QWEN_HOME'],
|
||||
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', 'GOOGLE_GEMINI_BASE_URL', ...GOOGLE_CLOUD_ENV_KEYS],
|
||||
opencode: [
|
||||
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY',
|
||||
'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT',
|
||||
],
|
||||
opencodereview: [
|
||||
'OCR_LLM_TOKEN', 'OCR_LLM_URL', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC',
|
||||
|
||||
@@ -8,6 +8,20 @@ const options = [
|
||||
{ optionId: 'reject', kind: 'reject_once', name: 'Reject' },
|
||||
]
|
||||
|
||||
test('read-only plan rejection exits instead of requesting a revision without feedback', () => {
|
||||
const request = { toolCall: { kind: 'switch_mode' }, options: [
|
||||
{ optionId: 'plan_approve', kind: 'allow_once', name: 'Approve' },
|
||||
{ optionId: 'plan_revise', kind: 'reject_once', name: 'Revise' },
|
||||
{ optionId: 'plan_reject_and_exit', kind: 'reject_once', name: 'Reject and Exit' },
|
||||
] }
|
||||
assert.deepEqual(automaticPermissionResponse(request, { allowWrite: false }), {
|
||||
status: 'rejected', optionId: 'plan_reject_and_exit',
|
||||
})
|
||||
assert.deepEqual(automaticPermissionResponse(request, { allowWrite: true }), {
|
||||
status: 'approved', optionId: 'plan_approve',
|
||||
})
|
||||
})
|
||||
|
||||
test('read-only turns approve only tools without side effects', () => {
|
||||
for (const kind of ['read', 'search', 'fetch', 'think']) {
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const adapters = require('../../../src/agents/cli/cli-adapters.cjs')
|
||||
const { automaticPermissionResponse } = require('../../../src/agents/agent-permission-policy.cjs')
|
||||
const { executable, within, readWhenReady } = require('../../support/cli-adapters-test-helpers.cjs')
|
||||
|
||||
test('Kimi read-only queries use manual mode and still reject writes', async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-query-'))
|
||||
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
|
||||
const cli = executable(directory, 'kimi', `
|
||||
const send = value => console.log(JSON.stringify(value))
|
||||
let promptId, mode
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line)
|
||||
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
|
||||
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId:'query'}})
|
||||
else if(m.method==='session/set_mode'){mode=m.params.modeId;send({jsonrpc:'2.0',id:m.id,result:{}})}
|
||||
else if(m.method==='session/prompt'){
|
||||
promptId=m.id
|
||||
send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId:'query',toolCall:{toolCallId:'write',kind:'edit',status:'pending',title:'Write file'},options:[{optionId:'allow',kind:'allow_once',name:'Allow'},{optionId:'reject',kind:'reject_once',name:'Reject'}]}})
|
||||
}else if(m.id===99&&m.result){
|
||||
const text=mode==='default'&&m.result.outcome?.optionId==='reject'?'ANSWER_WITHOUT_WRITES':'WRONG_MODE_OR_PERMISSION'
|
||||
send({jsonrpc:'2.0',method:'session/update',params:{sessionId:'query',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}})
|
||||
send({jsonrpc:'2.0',id:promptId,result:{stopReason:'end_turn'}})
|
||||
}
|
||||
})
|
||||
`)
|
||||
const result = await within(adapters.runAgent({kind:'kimi',executable:cli}, 'answer a query', directory, {
|
||||
sandbox:'read-only', onPermissionRequest:request=>automaticPermissionResponse(request,{allowWrite:false}),
|
||||
}),10000)
|
||||
assert.equal(result.text,'ANSWER_WITHOUT_WRITES')
|
||||
})
|
||||
|
||||
for (const callback of [false, true]) test(`Kimi plan review refuses and exits instead of revising without feedback: ${callback}`, async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-plan-'))
|
||||
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
|
||||
const selected = path.join(directory, 'selected')
|
||||
const cli = executable(directory, 'kimi', `
|
||||
const send = value => console.log(JSON.stringify(value))
|
||||
const sessionId = 'kimi-plan-session'
|
||||
let promptId
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line)
|
||||
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
|
||||
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId}})
|
||||
else if(m.method==='session/set_mode')send({jsonrpc:'2.0',id:m.id,result:{}})
|
||||
else if(m.method==='session/prompt'){
|
||||
promptId=m.id
|
||||
send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'Initial analysis and proposed next steps.'}}}})
|
||||
send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId,toolCall:{toolCallId:'plan',kind:'switch_mode',status:'pending',title:'ExitPlanMode'},options:[{optionId:'plan_approve',kind:'allow_once',name:'Approve'},{optionId:'plan_revise',kind:'reject_once',name:'Revise'},{optionId:'plan_reject_and_exit',kind:'reject_once',name:'Reject and Exit'}]}})
|
||||
}else if(m.id===99&&m.result){
|
||||
const id=m.result.outcome?.optionId
|
||||
require('node:fs').writeFileSync(${JSON.stringify(selected)},id||'cancelled')
|
||||
if(id==='plan_reject_and_exit')send({jsonrpc:'2.0',id:promptId,result:{stopReason:'end_turn'}})
|
||||
}
|
||||
})
|
||||
`)
|
||||
const result = await within(adapters.runAgent({ kind: 'kimi', executable: cli }, 'one independent contribution', directory, {
|
||||
...(callback ? { onPermissionRequest: request => automaticPermissionResponse(request, { allowWrite: false }) } : {}),
|
||||
}), 10000)
|
||||
assert.equal(await readWhenReady(selected), 'plan_reject_and_exit')
|
||||
assert.equal(result.text, 'Initial analysis and proposed next steps.')
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const adapters = require('../../../src/agents/cli/cli-adapters.cjs')
|
||||
const { executable, within } = require('../../support/cli-adapters-test-helpers.cjs')
|
||||
|
||||
for (const latePermission of [false, true]) test(`MiMo drains the exact message tail and rejects late authority: ${latePermission}`, async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-mimo-terminal-'))
|
||||
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
|
||||
const cli = executable(directory, 'mimo', `
|
||||
const send = value => console.log(JSON.stringify(value))
|
||||
const sessionId = 'mimo-terminal-session'
|
||||
const delta = (messageId, text) => send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',messageId,content:{type:'text',text}}}})
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line)
|
||||
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
|
||||
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId}})
|
||||
else if(m.method==='session/set_mode')send({jsonrpc:'2.0',id:m.id,result:{}})
|
||||
else if(m.method==='session/prompt'){
|
||||
delta('answer-1','MELDWORK')
|
||||
send({jsonrpc:'2.0',id:m.id,result:{stopReason:'end_turn'}})
|
||||
setTimeout(()=>{
|
||||
delta('answer-1','_OK')
|
||||
delta('unsolicited-message','MUST NOT MERGE')
|
||||
if(${latePermission})send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId,toolCall:{toolCallId:'late-write',kind:'edit',status:'pending'},options:[{optionId:'allow',kind:'allow_once',name:'Allow write'}]}})
|
||||
},10)
|
||||
}
|
||||
})
|
||||
`)
|
||||
let approvals = 0
|
||||
const run = within(adapters.runAgent({ kind: 'mimo', executable: cli }, 'return one answer', directory, {
|
||||
sandbox: 'workspace-write', onPermissionRequest: async () => { approvals++; return { status: 'approved', optionId: 'allow' } },
|
||||
}), 10000)
|
||||
if (latePermission) await assert.rejects(run, { code: 'LOCAL_AGENT_PERMISSION_TERMINAL_RACE' })
|
||||
else assert.equal((await run).text, 'MELDWORK_OK')
|
||||
assert.equal(approvals, 0)
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const adapters = require('../../../src/agents/cli/cli-adapters.cjs')
|
||||
const { normalizeFailure } = require('../../../src/runs/failure-policy.cjs')
|
||||
const { executable, within, readWhenReady } = require('../../support/cli-adapters-test-helpers.cjs')
|
||||
|
||||
for (const kind of ['opencode', 'mimo']) test(`${kind} native output loops are cancelled, preserve streamed text and never auto-retry as success`, async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-runaway-'))
|
||||
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
|
||||
const cancelled = path.join(directory, 'cancelled')
|
||||
const cli = executable(directory, kind, `
|
||||
const fs = require('node:fs')
|
||||
if (process.argv.includes('--help')) { console.log('opencode acp'); process.exit(0) }
|
||||
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
|
||||
const text = '# 表决:赞成——全场一致通过\\n我投赞成票,确认讨论阶段完结。全场只读,工作区零改动。成果就绪,等待用户回复授权写入后按共识计划交付 AI 硬件创业公司调研报告全文以及一页执行摘要。'
|
||||
let timer, request
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line', line => {
|
||||
const message = JSON.parse(line), sessionId = 'native-loop-session'
|
||||
if (message.method === 'initialize') send({jsonrpc:'2.0',id:message.id,result:{protocolVersion:1}})
|
||||
else if (message.method === 'session/new') send({jsonrpc:'2.0',id:message.id,result:{sessionId}})
|
||||
else if (message.method === 'session/set_mode') send({jsonrpc:'2.0',id:message.id,result:{}})
|
||||
else if (message.method === 'session/prompt') {
|
||||
request = message.id
|
||||
timer = setInterval(() => send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}}), 5)
|
||||
} else if (message.method === 'session/cancel') {
|
||||
clearInterval(timer); fs.writeFileSync(${JSON.stringify(cancelled)}, 'cancelled')
|
||||
send({jsonrpc:'2.0',id:request,result:{stopReason:'cancelled'}})
|
||||
}
|
||||
})
|
||||
`)
|
||||
const events = []
|
||||
await assert.rejects(within(adapters.runAgent({ kind, executable: cli }, 'return one answer', directory, {
|
||||
onEvent: event => events.push(event),
|
||||
}), 10000), error => {
|
||||
assert.equal(error.code, 'LOCAL_AGENT_REPEATED_OUTPUT')
|
||||
assert.equal(normalizeFailure(error).retryability, 'never')
|
||||
return true
|
||||
})
|
||||
assert.equal(await readWhenReady(cancelled), 'cancelled')
|
||||
const text = events.filter(event => event.type === 'answer_delta').map(event => event.delta).join('')
|
||||
assert.match(text, /表决:赞成/)
|
||||
assert.ok(text.length < 1500)
|
||||
assert.ok(!events.some(event => event.status === 'completed'))
|
||||
})
|
||||
|
||||
test('Hermes native JSON streaming is terminated when it repeats short acknowledgements forever', async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-hermes-runaway-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const terminated = path.join(directory, 'terminated')
|
||||
const cli = executable(directory, 'hermes', `
|
||||
process.on('SIGTERM', () => { require('node:fs').writeFileSync(${JSON.stringify(terminated)}, 'terminated'); process.exit(0) })
|
||||
setInterval(() => console.log(JSON.stringify({type:'text',text:'已确认,随时待命。'})), 5)
|
||||
`)
|
||||
const events = []
|
||||
await assert.rejects(within(adapters.runAgent({ kind: 'hermes', executable: cli, streamJsonAvailable: true }, 'one conclusion', directory, {
|
||||
onEvent: event => events.push(event),
|
||||
}), 10000), { code: 'LOCAL_AGENT_REPEATED_OUTPUT' })
|
||||
assert.equal(await readWhenReady(terminated), 'terminated')
|
||||
assert.match(events.filter(event => event.type === 'answer_delta').map(event => event.delta).join(''), /已确认/)
|
||||
})
|
||||
@@ -180,15 +180,15 @@ test('WorkBuddy JSON output returns the final reply and session id', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Kimi uses ACP plan mode by default and prompt mode only after write authorization', () => {
|
||||
test('Kimi uses ACP manual mode by default and prompt mode only after write authorization', () => {
|
||||
const chat = invocation('kimi', '/tmp/kimi', '/tmp/work')
|
||||
assert.deepEqual(chat.args, ['acp'])
|
||||
assert.equal(chat.acpMode, 'plan')
|
||||
assert.equal(chat.acpMode, 'default')
|
||||
assert.equal(chat.promptArg, undefined)
|
||||
|
||||
const resumed = invocation('kimi', '/tmp/kimi', '/tmp/work', 'kimi-session')
|
||||
assert.deepEqual(resumed.args, ['acp'])
|
||||
assert.equal(resumed.acpMode, 'plan')
|
||||
assert.equal(resumed.acpMode, 'default')
|
||||
|
||||
const writable = invocation('kimi', '/tmp/kimi', '/tmp/work', 'kimi-session', {
|
||||
sandbox: 'workspace-write',
|
||||
@@ -217,7 +217,7 @@ test('Kimi stream JSON output returns assistant text and the native session id',
|
||||
})
|
||||
})
|
||||
|
||||
test('Kimi ACP plan mode creates and resumes sessions while reporting incomplete turns', async (t) => {
|
||||
test('Kimi ACP manual mode creates and resumes sessions while reporting incomplete turns', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-acp-'))
|
||||
const workdir = fs.realpathSync(directory)
|
||||
const lifecycleFile = path.join(directory, 'lifecycle.log')
|
||||
@@ -349,7 +349,7 @@ input.on('line', (line) => {
|
||||
onEvent: event => createdEvents.push(event),
|
||||
},
|
||||
)
|
||||
assert.equal(created.text, `new|plan|cancelled|first prompt|${workdir}`)
|
||||
assert.equal(created.text, `new|default|cancelled|first prompt|${workdir}`)
|
||||
assert.equal(created.sessionRef, 'kimi-acp-session')
|
||||
assert.equal(created.outcome, 'completed')
|
||||
assert.deepEqual(createdSessionRefs, [{
|
||||
@@ -387,7 +387,7 @@ input.on('line', (line) => {
|
||||
(answer, event) => event.replace === true ? event.delta : answer + event.delta,
|
||||
'',
|
||||
),
|
||||
'new|plan|cancelled|first prompt|[path]',
|
||||
'new|default|cancelled|first prompt|[path]',
|
||||
)
|
||||
// Thinking surfaces only as a text-free reasoning status event.
|
||||
assert.equal(createdEvents.some(event => (
|
||||
@@ -421,7 +421,7 @@ input.on('line', (line) => {
|
||||
onSessionRef: (sessionRef, metadata) => resumedSessionRefs.push({ sessionRef, metadata }),
|
||||
},
|
||||
)
|
||||
assert.equal(resumed.text, `resume|plan|cancelled|next prompt|${workdir}`)
|
||||
assert.equal(resumed.text, `resume|default|cancelled|next prompt|${workdir}`)
|
||||
assert.equal(resumed.sessionRef, 'kimi-acp-session')
|
||||
assert.equal(resumed.outcome, 'completed')
|
||||
assert.deepEqual(resumedSessionRefs, [{
|
||||
@@ -450,7 +450,7 @@ input.on('line', (line) => {
|
||||
onSessionRef: sessionRef => privateSessionRefs.push(sessionRef),
|
||||
},
|
||||
)
|
||||
assert.equal(privateSession.text, `new|plan|cancelled|private session prompt|${workdir}`)
|
||||
assert.equal(privateSession.text, `new|default|cancelled|private session prompt|${workdir}`)
|
||||
assert.equal(privateSession.sessionRef, '')
|
||||
assert.deepEqual(privateSessionRefs, [])
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { nativeCredentialEnvironment } = require('../../../src/agents/local-agent-readiness.cjs')
|
||||
const { childEnvironment } = require('../../../src/agents/cli/cli-process-support.cjs')
|
||||
|
||||
test('native configuration controls reach only the CLI that owns them', () => {
|
||||
const entries = {
|
||||
codex: { CODEX_HOME: '/native/codex' },
|
||||
hermes: { HERMES_HOME: '/native/hermes' },
|
||||
kimi: { KIMI_CODE_HOME: '/native/kimi', KIMI_MODEL_PROVIDER_TYPE: 'openai' },
|
||||
mimo: { MIMOCODE_CONFIG_DIR: '/native/mimo', MIMOCODE_CONFIG_CONTENT: '{"model":"local/model"}' },
|
||||
opencode: { OPENCODE_CONFIG: '/native/opencode.json', OPENCODE_CONFIG_DIR: '/native/opencode', OPENCODE_CONFIG_CONTENT: '{"model":"local/model"}' },
|
||||
qwen: { QWEN_HOME: '/native/qwen' },
|
||||
gemini: { GOOGLE_GEMINI_BASE_URL: 'http://127.0.0.1:3425' },
|
||||
}
|
||||
const shell = { ...Object.assign({}, ...Object.values(entries)), UNRELATED_SECRET: 'must-not-leak', NODE_OPTIONS: '--require /untrusted.js' }
|
||||
for (const [kind, expected] of Object.entries(entries)) {
|
||||
const selected = nativeCredentialEnvironment(kind, shell)
|
||||
assert.deepEqual(selected, expected, kind)
|
||||
const child = childEnvironment({ kind, executable: '/native/cli' }, '/workspace', { env: selected }, 'darwin')
|
||||
for (const [key, value] of Object.entries(expected)) assert.equal(child[key], value, `${kind}: ${key}`)
|
||||
assert.equal(child.NODE_OPTIONS, undefined)
|
||||
assert.equal(child.UNRELATED_SECRET, undefined)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { createOutputRepetitionGuard } = require('../../../src/agents/cli/cli-output-repetition.cjs')
|
||||
|
||||
const answer = '# 表决:赞成——全场一致通过\n\n我投赞成票,确认讨论阶段完结。全场只读,工作区零改动。\n\n成果就绪,等待用户回复“**授权写入**”后按共识计划交付《AI 硬件创业公司调研报告.md》全文 + 一页执行摘要。'
|
||||
const update = text => ({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } })
|
||||
|
||||
test('detects the reported Chinese vote loop across arbitrary stream chunks', () => {
|
||||
for (const size of [1, 7, 83, 257]) {
|
||||
const guard = createOutputRepetitionGuard()
|
||||
const text = answer.repeat(80)
|
||||
let stoppedAt = 0
|
||||
for (let offset = 0; offset < text.length; offset += size) {
|
||||
if (guard(update(text.slice(offset, offset + size)))) { stoppedAt = offset + size; break }
|
||||
}
|
||||
assert.ok(stoppedAt > 0 && stoppedAt < answer.length * 7, `chunk size ${size}: ${stoppedAt}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('does not reject ordinary long answers, repeated characters or three quotations', () => {
|
||||
for (const text of [answer.repeat(3), '*'.repeat(30000), Array.from({ length: 500 }, (_, index) => `Company ${index}: evidence varies by company and round ${index}.\n`).join('')]) {
|
||||
const guard = createOutputRepetitionGuard()
|
||||
for (let offset = 0; offset < text.length; offset += 41) assert.equal(guard(update(text.slice(offset, offset + 41))), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('real tools reset the window but repeated thoughts do not hide an output loop', () => {
|
||||
const guard = createOutputRepetitionGuard()
|
||||
for (let index = 0; index < 10; index++) {
|
||||
assert.equal(guard(update(answer.repeat(2))), false)
|
||||
guard({ sessionUpdate: 'tool_call', toolCallId: `tool-${index}`, status: 'in_progress' })
|
||||
}
|
||||
let repeated = false
|
||||
for (let index = 0; index < 8 && !repeated; index++) {
|
||||
guard({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking' } })
|
||||
repeated = guard(update(answer))
|
||||
}
|
||||
assert.equal(repeated, true)
|
||||
})
|
||||
|
||||
test('independent turns do not share repetition state', () => {
|
||||
for (let index = 0; index < 8; index++) assert.equal(createOutputRepetitionGuard()(update(answer.repeat(2))), false)
|
||||
})
|
||||
|
||||
test('detects MiMo short acknowledgements and native JSON-stream answer deltas', () => {
|
||||
for (const phrase of ['已确认,随时待命。', 'MELDWORK_OK']) {
|
||||
const guard = createOutputRepetitionGuard()
|
||||
let count = 0
|
||||
while (++count < 20 && !guard({ type: 'answer_delta', delta: phrase })) {}
|
||||
assert.ok(count < 20, phrase)
|
||||
}
|
||||
})
|
||||
@@ -50,6 +50,36 @@ function decisionReply(text, status = 'completed', extra = {}) {
|
||||
})}]]`
|
||||
}
|
||||
|
||||
test('a native repetition failure preserves the partial proposal through reload', { timeout: 10000 }, async t => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const entered = deferred()
|
||||
const release = deferred()
|
||||
options.naturalAgentResponses = true
|
||||
options.runAgent = async (agent, _prompt, _workdir, runOptions) => {
|
||||
if (agent.kind === 'codex') return { text: 'Independent analysis completed.', outcome: 'completed' }
|
||||
runOptions.onEvent({ type: 'answer_delta', status: 'running', delta: '已完成的初步研究结果。' })
|
||||
entered.resolve()
|
||||
await release.promise
|
||||
throw new Error('LOCAL_AGENT_REPEATED_OUTPUT')
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'Repetition failure', agentKinds: ['codex', 'hermes'], workdir: directory })
|
||||
const sending = workspace.sendMessage({ groupId: group.id, text: 'Research', mode: 'auto', protocol: 'v4',
|
||||
targetKinds: group.agentKinds, discussionStyle: 'agent-led', maxRounds: 1 })
|
||||
await entered.promise
|
||||
const controller = workspace.activeRuns.get(group.id)
|
||||
release.resolve()
|
||||
await sending
|
||||
await controller.promise
|
||||
const reopened = new LocalWorkspace(options)
|
||||
assert.ok(reopened.snapshot().messages.some(message => (
|
||||
message.system?.params?.reason === 'LOCAL_AGENT_REPEATED_OUTPUT'
|
||||
&& message.content.includes('已完成的初步研究结果。')
|
||||
)))
|
||||
})
|
||||
|
||||
function naturalPhase(prompt) {
|
||||
if (prompt.includes('Work independently on the user task')) return 'proposal'
|
||||
if (prompt.includes('Continue from the available peer responses')) return 'discussion'
|
||||
|
||||
@@ -50,6 +50,20 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('composer context', () => {
|
||||
it('keeps the selected discussion mode when the current group membership refreshes', () => {
|
||||
const { activeGroup, composer, scope } = createComposer()
|
||||
activeGroup.value = { id: 'group-auto', agentKinds: ['codex', 'hermes'] }
|
||||
composer.resetComposerContext(activeGroup.value)
|
||||
composer.discussionMode.value = 'auto-beta'
|
||||
composer.resetComposerContext({ ...activeGroup.value, agentKinds: ['hermes', 'codex'] })
|
||||
expect(composer.discussionMode.value).toBe('auto-beta')
|
||||
composer.resetComposerContext({ id: 'other-group', agentKinds: ['codex', 'hermes'] })
|
||||
expect(composer.discussionMode.value).toBe('auto')
|
||||
composer.resetComposerContext(activeGroup.value)
|
||||
expect(composer.discussionMode.value).toBe('auto-beta')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('treats an empty manual selection as the whole group', () => {
|
||||
const { activeGroup, composer, scope } = createComposer()
|
||||
activeGroup.value = {
|
||||
|
||||
@@ -141,6 +141,8 @@ describe('Meldwork i18n', () => {
|
||||
.toBe('Sign in to this Agent or configure its credentials, then try again.')
|
||||
expect(translateError(new Error('LOCAL_AGENT_PROCESS_FAILED')))
|
||||
.toBe('The Agent process failed before completing the task.')
|
||||
expect(translateError(new Error('LOCAL_AGENT_REPEATED_OUTPUT')))
|
||||
.toContain('kept repeating the same response')
|
||||
expect(translateError(new Error('LOCAL_AGENT_TIMEOUT')))
|
||||
.toBe('This Agent took too long to respond. Try again or start a new conversation.')
|
||||
expect(translateSystemMessage(systemMessage('LOCAL_AGENT_EXECUTION_STOPPED')))
|
||||
@@ -159,6 +161,8 @@ describe('Meldwork i18n', () => {
|
||||
.toBe('请先登录该 Agent 或完成凭据配置后再试。')
|
||||
expect(translateError(new Error('LOCAL_AGENT_PROCESS_FAILED')))
|
||||
.toBe('Agent 进程在任务完成前执行失败。')
|
||||
expect(translateError(new Error('LOCAL_AGENT_REPEATED_OUTPUT')))
|
||||
.toContain('持续重复相同回复')
|
||||
expect(translateError(new Error('LOCAL_AGENT_TIMEOUT')))
|
||||
.toBe('该 Agent 响应超时,请重试或新建会话后继续。')
|
||||
expect(translateSystemMessage(systemMessage('LOCAL_AGENT_EXECUTION_STOPPED')))
|
||||
|
||||
@@ -253,6 +253,8 @@ export function useComposerContext({
|
||||
// was typed. Attachments and mentions are still reset: they are tied to the
|
||||
// conversation's members and to files staged for it.
|
||||
const draftsByGroupId = new Map()
|
||||
const modesByGroupId = new Map()
|
||||
let contextGroupId = ''
|
||||
|
||||
function stashComposerDraft(groupId) {
|
||||
const id = String(groupId || '')
|
||||
@@ -269,10 +271,14 @@ export function useComposerContext({
|
||||
function pruneComposerDrafts(groupIds) {
|
||||
const live = new Set((Array.isArray(groupIds) ? groupIds : []).map(String))
|
||||
for (const id of draftsByGroupId.keys()) if (!live.has(id)) draftsByGroupId.delete(id)
|
||||
for (const id of modesByGroupId.keys()) if (!live.has(id)) modesByGroupId.delete(id)
|
||||
}
|
||||
|
||||
function resetComposerContext(group) {
|
||||
discussionMode.value = group?.conversationType === 'direct' ? 'manual' : 'auto'
|
||||
if (contextGroupId) modesByGroupId.set(contextGroupId, discussionMode.value)
|
||||
contextGroupId = String(group?.id || '')
|
||||
discussionMode.value = group?.conversationType === 'direct'
|
||||
? 'manual' : modesByGroupId.get(contextGroupId) || 'auto'
|
||||
automaticTeamFormation.value = false
|
||||
targetKinds.value = group?.conversationType === 'direct' ? [...group.agentKinds] : []
|
||||
clearComposerContext()
|
||||
|
||||
@@ -105,6 +105,7 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
|
||||
LOCAL_AGENT_ACCESS_DENIED: 'error.agentAccessDenied',
|
||||
LOCAL_AGENT_SESSION_INVALID: 'error.agentSessionInvalid',
|
||||
LOCAL_AGENT_OUTPUT_LIMIT: 'error.agentOutputLimit',
|
||||
LOCAL_AGENT_REPEATED_OUTPUT: 'error.agentRepeatedOutput',
|
||||
LOCAL_AGENT_SEND_FAILED: 'error.agentSendFailed',
|
||||
LOCAL_AGENT_REFUSED: 'error.agentRefused',
|
||||
LOCAL_AGENT_PERMISSION_TERMINAL_RACE: 'error.agentPermissionRace',
|
||||
|
||||
@@ -896,6 +896,7 @@ export default {
|
||||
'error.runCircuitBreaker': 'This run stopped after too many failed attempts.',
|
||||
'error.agentAuthRequired': 'Sign in to this Agent or configure its credentials, then try again.',
|
||||
'error.agentProcessFailed': 'The Agent process failed before completing the task.',
|
||||
'error.agentRepeatedOutput': 'The Agent kept repeating the same response. This call was stopped and received output was preserved. Check the CLI model/provider configuration before trying again.',
|
||||
'error.customAgentLimit': 'You can connect up to 32 custom Agents.',
|
||||
'error.customAgentLabel': 'Enter a valid Agent name.',
|
||||
'error.customAgentDescription': 'Keep the description within 240 characters.',
|
||||
|
||||
@@ -896,6 +896,7 @@ export default {
|
||||
'error.runCircuitBreaker': '失败次数过多,本次运行已停止。',
|
||||
'error.agentAuthRequired': '请先登录该 Agent 或完成凭据配置后再试。',
|
||||
'error.agentProcessFailed': 'Agent 进程在任务完成前执行失败。',
|
||||
'error.agentRepeatedOutput': '该 Agent 持续重复相同回复,本次调用已停止,已收到的内容已保留。请检查 CLI 的模型或服务商配置后再试。',
|
||||
'error.customAgentLimit': '最多可接入 32 个自定义 Agent。',
|
||||
'error.customAgentLabel': '请输入有效的 Agent 名称。',
|
||||
'error.customAgentDescription': '能力描述不能超过 240 个字符。',
|
||||
|
||||
Reference in new issue
Block a user