mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
fix(commands): support both Harness command argument formats
Keep images for newer Harness endpoints and retry without it only when an older gateway rejects the field before dispatch. Preserve cancellation and never retry business failures. Add regression coverage, rebuild the Host bundle, and document the compatibility fix. Follow-up to #75.
This commit is contained in:
parent
b9e53bfd69
commit
4c4c748864
4 changed files with 143 additions and 14 deletions
|
|
@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed / 修复
|
||||
|
||||
- `/compact` 同时兼容要求 `images` 字段的新 Harness 与不接受该字段的旧 Harness;仅在网关明确拒绝多余字段、命令尚未执行时回退,避免重复压缩。
|
||||
`/compact` now supports both newer Harness endpoints that require `images` and older endpoints that reject it, falling back only after an explicit pre-execution field rejection to avoid duplicate compaction.
|
||||
|
||||
## [3.0.6] - 2026-08-26
|
||||
|
||||
### Fixed / 修复
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -12,14 +12,30 @@ export function createHarnessCommandExecutor(ctx, provided) {
|
|||
if (typeof gateway.invoke !== 'function') {
|
||||
throw new TypeError('dsh-im requires a callable ctx.typertGateway');
|
||||
}
|
||||
return (sessionId, line, options = {}) => gateway.invoke({
|
||||
namespace: 'commands',
|
||||
method: 'execute',
|
||||
// The Harness commands execute descriptor declares agentId, line, and
|
||||
// images as required JSON wire fields; omitting images makes the Typert
|
||||
// gateway reject the call with arguments-invalid, so IM /compact always
|
||||
// carried an empty image list.
|
||||
args: { agentId: sessionId, line, images: [] },
|
||||
signal: options.signal,
|
||||
});
|
||||
return async (sessionId, line, options = {}) => {
|
||||
const request = {
|
||||
namespace: 'commands',
|
||||
method: 'execute',
|
||||
args: { agentId: sessionId, line, images: [] },
|
||||
signal: options.signal,
|
||||
};
|
||||
try {
|
||||
return await gateway.invoke(request);
|
||||
} catch (error) {
|
||||
// Newer Hosts require images; older Hosts reject that field before
|
||||
// invoking the command. Retry only that exact pre-dispatch failure so
|
||||
// a business failure can never cause compaction to run twice.
|
||||
if (error?.name !== 'TypertGatewayError'
|
||||
|| error.code !== 'arguments-invalid'
|
||||
|| error.endpoint !== 'commands/execute'
|
||||
|| error.message !== 'typert gateway: commands/execute: args fields do not match the descriptor: unexpected "images"') {
|
||||
throw error;
|
||||
}
|
||||
options.signal?.throwIfAborted();
|
||||
return gateway.invoke({
|
||||
...request,
|
||||
args: { agentId: sessionId, line },
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@ function state(sessionId = 'session-one') {
|
|||
return { sessionFor: () => sessionId };
|
||||
}
|
||||
|
||||
function legacyImagesArgumentError(overrides = {}) {
|
||||
return Object.assign(new Error(
|
||||
'typert gateway: commands/execute: args fields do not match the descriptor: unexpected "images"',
|
||||
), {
|
||||
name: 'TypertGatewayError',
|
||||
code: 'arguments-invalid',
|
||||
endpoint: 'commands/execute',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('compact command validates syntax and requires an existing conversation Session', async () => {
|
||||
assert.equal(await runCompactCommand('hello', {}, state(), 'direct:one'), null);
|
||||
assert.match(
|
||||
|
|
@ -145,9 +156,7 @@ test('Host command executor invokes the commands Typert endpoint with the Sessio
|
|||
assert.deepEqual(requests, [{
|
||||
namespace: 'commands',
|
||||
method: 'execute',
|
||||
// images must ride the wire as an empty array: the Harness commands
|
||||
// execute descriptor declares it a required JSON field, and omitting it
|
||||
// makes the Typert gateway reject the call with arguments-invalid.
|
||||
// Newer Harness descriptors require images even for plain commands.
|
||||
args: { agentId: 'session-one', line: '/compact', images: [] },
|
||||
signal,
|
||||
}]);
|
||||
|
|
@ -155,6 +164,105 @@ test('Host command executor invokes the commands Typert endpoint with the Sessio
|
|||
assert.throws(() => createHarnessCommandExecutor({}, 'invalid'), /must be a function/);
|
||||
});
|
||||
|
||||
test('compact executes once on older Harness after its gateway rejects the images field', async () => {
|
||||
const requests = [];
|
||||
let executions = 0;
|
||||
const signal = new AbortController().signal;
|
||||
const executor = createHarnessCommandExecutor({
|
||||
typertGateway: { invoke: async (request) => {
|
||||
requests.push(request);
|
||||
if (Object.hasOwn(request.args, 'images')) throw legacyImagesArgumentError();
|
||||
executions += 1;
|
||||
return {
|
||||
commandId: 'command-one',
|
||||
result: { kind: 'success', text: 'Compacted 5 history items (~200 tokens).' },
|
||||
};
|
||||
} },
|
||||
});
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:1', workspace: '/tmp', commandExecutor: executor,
|
||||
});
|
||||
|
||||
const result = await runCompactCommand('/compact', client, state(), 'direct:one', { signal });
|
||||
assert.equal(result.message, '已压缩 5 条历史记录(约 200 个 token)。');
|
||||
assert.equal(executions, 1);
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
namespace: 'commands', method: 'execute',
|
||||
args: { agentId: 'session-one', line: '/compact', images: [] }, signal,
|
||||
},
|
||||
{
|
||||
namespace: 'commands', method: 'execute',
|
||||
args: { agentId: 'session-one', line: '/compact' }, signal,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('Host command executor never retries other gateway or business failures', async () => {
|
||||
const message = legacyImagesArgumentError().message;
|
||||
for (const failure of [
|
||||
new Error(message),
|
||||
legacyImagesArgumentError({ name: 'CommandError' }),
|
||||
legacyImagesArgumentError({ code: 'result-invalid' }),
|
||||
legacyImagesArgumentError({ endpoint: 'other/execute' }),
|
||||
legacyImagesArgumentError({ message: message.replace('unexpected "images"', 'missing "images"') }),
|
||||
legacyImagesArgumentError({ message: `${message}, "other"` }),
|
||||
Object.assign(new Error('busy'), { failure: { code: 'agent-busy' } }),
|
||||
new Error('compaction failed after starting'),
|
||||
]) {
|
||||
let attempts = 0;
|
||||
const executor = createHarnessCommandExecutor({
|
||||
typertGateway: { invoke: async () => { attempts += 1; throw failure; } },
|
||||
});
|
||||
await assert.rejects(executor('session-one', '/compact'), (error) => error === failure);
|
||||
assert.equal(attempts, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Host command executor does not repeat a failed legacy invocation', async () => {
|
||||
let attempts = 0;
|
||||
const failure = new Error('compaction failed after starting');
|
||||
const executor = createHarnessCommandExecutor({
|
||||
typertGateway: { invoke: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw legacyImagesArgumentError();
|
||||
throw failure;
|
||||
} },
|
||||
});
|
||||
await assert.rejects(executor('session-one', '/compact'), (error) => error === failure);
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('Host command executor preserves an unresolved command on the legacy endpoint', async () => {
|
||||
let attempts = 0;
|
||||
const executor = createHarnessCommandExecutor({
|
||||
typertGateway: { invoke: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw legacyImagesArgumentError();
|
||||
return undefined;
|
||||
} },
|
||||
});
|
||||
assert.equal(await executor('session-one', '/compact'), undefined);
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('Host command executor honours cancellation before retrying the legacy endpoint', async () => {
|
||||
let attempts = 0;
|
||||
const controller = new AbortController();
|
||||
const executor = createHarnessCommandExecutor({
|
||||
typertGateway: { invoke: async () => {
|
||||
attempts += 1;
|
||||
controller.abort();
|
||||
throw legacyImagesArgumentError();
|
||||
} },
|
||||
});
|
||||
await assert.rejects(
|
||||
executor('session-one', '/compact', { signal: controller.signal }),
|
||||
(error) => error === controller.signal.reason,
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test('all nine production channels receive the Host command executor', async () => {
|
||||
for (const path of PRODUCTION_FILES) {
|
||||
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue