mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix(cron): show full run reply from transcript instead of truncated summary (#1222)
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import { getOpenClawConfigDir } from '../utils/paths';
|
||||
import { resolveAgentIdFromChannel } from '../utils/agent-config';
|
||||
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
|
||||
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
|
||||
import { loadSessionTranscriptByKey } from './sessions-api';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
interface GatewayCronJob {
|
||||
@@ -60,6 +62,7 @@ interface CronSessionFallbackMessage {
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
const OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS = 2_000;
|
||||
|
||||
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
@@ -93,14 +96,83 @@ function formatDuration(durationMs: number | undefined): string | null {
|
||||
return `${Math.round(durationMs / 1000)}s`;
|
||||
}
|
||||
|
||||
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
|
||||
function getMessageText(content: RawMessage['content']): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== 'object') return '';
|
||||
const value = block as { type?: unknown; text?: unknown };
|
||||
return value.type === 'text' && typeof value.text === 'string' ? value.text : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getFinalAssistantReply(messages: RawMessage[]): string {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message?.role !== 'assistant') continue;
|
||||
const text = getMessageText(message.content);
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isBoundedCronSummary(summary: string): boolean {
|
||||
return summary.length >= OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS
|
||||
&& summary.endsWith('…');
|
||||
}
|
||||
|
||||
function resolveCronRunSessionKey(
|
||||
parsed: CronSessionKeyParts,
|
||||
entry: CronRunLogEntry,
|
||||
): string | null {
|
||||
const explicitSessionKey = typeof entry.sessionKey === 'string' ? entry.sessionKey.trim() : '';
|
||||
if (explicitSessionKey && parseCronSessionKey(explicitSessionKey)?.runSessionId) {
|
||||
return explicitSessionKey;
|
||||
}
|
||||
const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId.trim() : '';
|
||||
if (!sessionId) return null;
|
||||
return `agent:${parsed.agentId}:cron:${parsed.jobId}:run:${sessionId}`;
|
||||
}
|
||||
|
||||
async function loadFullCronRunReplies(
|
||||
parsed: CronSessionKeyParts,
|
||||
runs: CronRunLogEntry[],
|
||||
): Promise<Map<CronRunLogEntry, string>> {
|
||||
const replies = new Map<CronRunLogEntry, string>();
|
||||
await Promise.all(runs.map(async (entry) => {
|
||||
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
|
||||
if (!isBoundedCronSummary(summary)) return;
|
||||
|
||||
const runSessionKey = resolveCronRunSessionKey(parsed, entry);
|
||||
if (!runSessionKey) return;
|
||||
const transcript = await loadSessionTranscriptByKey(runSessionKey, 1_000);
|
||||
if (!transcript?.length) return;
|
||||
|
||||
const fullReply = getFinalAssistantReply(transcript);
|
||||
const summaryPrefix = summary.slice(0, -1);
|
||||
if (fullReply.length > summaryPrefix.length && fullReply.startsWith(summaryPrefix)) {
|
||||
replies.set(entry, fullReply);
|
||||
}
|
||||
}));
|
||||
return replies;
|
||||
}
|
||||
|
||||
function buildCronRunMessage(
|
||||
entry: CronRunLogEntry,
|
||||
index: number,
|
||||
fullReply?: string,
|
||||
): CronSessionFallbackMessage | null {
|
||||
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
|
||||
if (!timestamp) return null;
|
||||
|
||||
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
|
||||
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
|
||||
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
|
||||
let content = summary || error;
|
||||
let content = fullReply?.trim() || summary || error;
|
||||
if (!content) {
|
||||
content = status === 'error' ? 'Scheduled task failed.' : 'Scheduled task completed.';
|
||||
}
|
||||
@@ -195,6 +267,7 @@ function buildCronSessionFallbackMessages(params: {
|
||||
sessionKey: string;
|
||||
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
|
||||
runs: CronRunLogEntry[];
|
||||
fullReplies?: Map<CronRunLogEntry, string>;
|
||||
sessionEntry?: { label?: string; updatedAt?: number };
|
||||
limit?: number;
|
||||
}): CronSessionFallbackMessage[] {
|
||||
@@ -231,7 +304,7 @@ function buildCronSessionFallbackMessages(params: {
|
||||
}
|
||||
|
||||
matchingRuns.forEach((entry, index) => {
|
||||
const message = buildCronRunMessage(entry, index);
|
||||
const message = buildCronRunMessage(entry, index, params.fullReplies?.get(entry));
|
||||
if (message) messages.push(message);
|
||||
});
|
||||
|
||||
@@ -583,11 +656,13 @@ export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
]);
|
||||
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
|
||||
const job = jobs.find((item) => item.id === parsedSession.jobId);
|
||||
const fullReplies = await loadFullCronRunReplies(parsedSession, runs);
|
||||
return {
|
||||
messages: buildCronSessionFallbackMessages({
|
||||
sessionKey,
|
||||
job,
|
||||
runs,
|
||||
fullReplies,
|
||||
sessionEntry: sessionEntry ? {
|
||||
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
|
||||
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
|
||||
|
||||
@@ -473,7 +473,7 @@ async function loadSessionSummary(sessionKey: string, workspacePath: string | nu
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
|
||||
export async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
|
||||
const parsed = parseSessionKey(sessionKey);
|
||||
if (!parsed) return null;
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ appliesTo:
|
||||
|
||||
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while ordinary live prompt updates continue through host events. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
|
||||
|
||||
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must come from Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
|
||||
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
|
||||
|
||||
Historical transcript reads are limited to the newest `1000` message records. A successful live prompt reads content immediately and retries exactly once after `1500 ms`. General attachment and timing alignment treat history as a suffix and match the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; they must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live content alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline or timing annotations.
|
||||
|
||||
@@ -84,7 +84,7 @@ Renderer code must not create direct Gateway WebSocket connections. Gateway fram
|
||||
|
||||
Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark.
|
||||
|
||||
Scheduled-task history is Main-owned backend data. Current OpenClaw versions must be queried through the Gateway `cron.runs` RPC; direct file reads are allowed only as a compatibility fallback for older file-backed runtimes. When a cron base session has no ACP replay, Renderer may project that typed host result into a generation-scoped, in-memory historical ACP timeline, but must not replace or duplicate non-empty ACP replay.
|
||||
Scheduled-task history is Main-owned backend data. Current OpenClaw versions must be queried through the Gateway `cron.runs` RPC; direct run-log file reads are allowed only as a compatibility fallback for older file-backed runtimes. When a run's bounded summary ends with OpenClaw's truncation ellipsis, Main may recover the complete final assistant reply from the run transcript identified by that `cron.runs` entry, but only when the transcript reply is longer and shares the entire summary prefix. When a cron base session has no ACP replay, Renderer may project that typed host result into a generation-scoped, in-memory historical ACP timeline, but must not replace or duplicate non-empty ACP replay.
|
||||
|
||||
The local HTML Preview privileged bridge is also Main-owned: Renderer may load a validated local HTML file or open that current file externally through the typed Host API. The guest is an implementation detail of the existing `preview` tab; there is no `web-browser` artifact tab or general address navigation. The durable guest contract is `harness/reference/web-browser.md`.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: fix-cron-job-summary-truncation
|
||||
title: Restore complete scheduled-task replies from run transcripts
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent completed Cron conversations from ending at OpenClaw's bounded run-summary limit when the full run transcript is available.
|
||||
touchedAreas:
|
||||
- electron/services/cron-api.ts
|
||||
- electron/services/sessions-api.ts
|
||||
- tests/unit/cron-schedule.test.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- harness/specs/tasks/fix-cron-job-summary-truncation.md
|
||||
expectedUserBehavior:
|
||||
- Opening a scheduled-task conversation shows the complete final assistant reply instead of a 2000-character Cron summary ending in an ellipsis.
|
||||
- Duration and model metadata remain visible after the restored reply.
|
||||
- Missing or unreadable run transcripts continue to fall back to the bounded Cron summary.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/cron-schedule.test.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
acceptance:
|
||||
- Electron Main remains the owner of scheduled-task history and continues to query Gateway cron.runs.
|
||||
- A summary matching OpenClaw's bounded-summary envelope is replaced only when the corresponding run transcript contains a longer assistant reply with the same prefix.
|
||||
- Run lookup accepts an explicit run-scoped sessionKey and can derive one from agent ID, job ID, and sessionId.
|
||||
- Short summaries, mismatched transcript text, and unavailable transcripts are returned unchanged.
|
||||
- ACP replay remains authoritative; restored Cron history is projected only when replay is empty.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
Use this task spec for the Cron history fallback repair that joins bounded
|
||||
`cron.runs` summaries with their corresponding on-disk run transcripts.
|
||||
@@ -169,6 +169,7 @@ test.describe('ClawX cron run live status', () => {
|
||||
|
||||
test('shows cron run summaries when ACP replay is empty', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
const completeCronReply = `该喝水了!💧\n\n${'补充说明 '.repeat(500)}\n\n完整回复结尾`;
|
||||
|
||||
try {
|
||||
const cronSession = {
|
||||
@@ -198,7 +199,7 @@ test.describe('ClawX cron run live status', () => {
|
||||
[stableStringify(['cron', 'sessionHistory', { sessionKey: CRON_BASE_KEY, limit: 200 }])]: {
|
||||
messages: [
|
||||
{ id: 'cron-prompt', role: 'user', content: '提醒我喝水', timestamp: Date.now() - 5000 },
|
||||
{ id: 'cron-result', role: 'assistant', content: '该喝水了!💧', timestamp: Date.now() },
|
||||
{ id: 'cron-result', role: 'assistant', content: completeCronReply, timestamp: Date.now() },
|
||||
],
|
||||
},
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
@@ -231,6 +232,7 @@ test.describe('ClawX cron run live status', () => {
|
||||
))).toBe(true);
|
||||
await expect(page.getByText('提醒我喝水')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('该喝水了!💧')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('完整回复结尾')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('acp-chat-empty-state')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createCronApi } from '../../electron/services/cron-api';
|
||||
import type { GatewayManager } from '../../electron/gateway/manager';
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
loadSessionTranscriptByKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../electron/services/sessions-api', () => ({
|
||||
loadSessionTranscriptByKey: sessionMocks.loadSessionTranscriptByKey,
|
||||
}));
|
||||
|
||||
type RpcParams = {
|
||||
schedule?: Record<string, unknown>;
|
||||
patch?: { schedule?: Record<string, unknown> };
|
||||
@@ -70,6 +78,10 @@ describe('cron schedule normalization', () => {
|
||||
});
|
||||
|
||||
describe('cron session history', () => {
|
||||
beforeEach(() => {
|
||||
sessionMocks.loadSessionTranscriptByKey.mockReset();
|
||||
});
|
||||
|
||||
it('reads SQLite-backed run summaries through cron.runs', async () => {
|
||||
const job = makeGatewayJob({ kind: 'cron', expr: '* * * * *' });
|
||||
const rpc = vi.fn(async (method: string) => {
|
||||
@@ -109,5 +121,84 @@ describe('cron session history', () => {
|
||||
{ role: 'assistant', content: expect.stringContaining('Time to drink water.') },
|
||||
],
|
||||
});
|
||||
expect(sessionMocks.loadSessionTranscriptByKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores a bounded run summary from the derived run transcript key', async () => {
|
||||
const job = makeGatewayJob({ kind: 'cron', expr: '* * * * *' });
|
||||
const summaryPrefix = 'A'.repeat(2000);
|
||||
const fullReply = `${summaryPrefix}${'B'.repeat(500)}`;
|
||||
const rpc = vi.fn(async (method: string) => {
|
||||
if (method === 'cron.list') return { jobs: [job] };
|
||||
if (method === 'cron.runs') {
|
||||
return {
|
||||
entries: [{
|
||||
jobId: 'job-1',
|
||||
status: 'ok',
|
||||
summary: `${summaryPrefix}…`,
|
||||
sessionId: 'run-session-1',
|
||||
ts: 1_700_000_005_000,
|
||||
runAtMs: 1_700_000_000_000,
|
||||
durationMs: 5000,
|
||||
provider: 'provider-a',
|
||||
model: 'model-a',
|
||||
}],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
sessionMocks.loadSessionTranscriptByKey.mockResolvedValue([
|
||||
{ role: 'user', content: 'hi' },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: fullReply }], stopReason: 'stop' },
|
||||
]);
|
||||
const api = createCronApi({ gatewayManager: { rpc } as unknown as GatewayManager });
|
||||
|
||||
const result = await api.sessionHistory({
|
||||
sessionKey: 'agent:main:cron:job-1',
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
expect(sessionMocks.loadSessionTranscriptByKey).toHaveBeenCalledWith(
|
||||
'agent:main:cron:job-1:run:run-session-1',
|
||||
1000,
|
||||
);
|
||||
expect(result.messages?.[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: `${fullReply}\n\nDuration: 5.0s | Model: provider-a/model-a`,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the bounded summary when the transcript does not share its prefix', async () => {
|
||||
const job = makeGatewayJob({ kind: 'cron', expr: '* * * * *' });
|
||||
const summary = `${'A'.repeat(2000)}…`;
|
||||
const runSessionKey = 'agent:main:cron:job-1:run:run-session-1';
|
||||
const rpc = vi.fn(async (method: string) => {
|
||||
if (method === 'cron.list') return { jobs: [job] };
|
||||
if (method === 'cron.runs') {
|
||||
return {
|
||||
entries: [{
|
||||
jobId: 'job-1',
|
||||
status: 'ok',
|
||||
summary,
|
||||
sessionId: 'ignored-session-id',
|
||||
sessionKey: runSessionKey,
|
||||
ts: 1_700_000_005_000,
|
||||
}],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
sessionMocks.loadSessionTranscriptByKey.mockResolvedValue([
|
||||
{ role: 'assistant', content: `${'X'.repeat(2000)}more` },
|
||||
]);
|
||||
const api = createCronApi({ gatewayManager: { rpc } as unknown as GatewayManager });
|
||||
|
||||
const result = await api.sessionHistory({
|
||||
sessionKey: 'agent:main:cron:job-1',
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
expect(sessionMocks.loadSessionTranscriptByKey).toHaveBeenCalledWith(runSessionKey, 1000);
|
||||
expect(result.messages?.[1]).toMatchObject({ role: 'assistant', content: summary });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user