{ isSystem
? __( 'System', 'clawpress' )
@@ -174,7 +208,9 @@ const PanelMessages = ( {
/>
)
) }
- { waitingForResponse && ! currentStreamText ? (
+ { waitingForResponse &&
+ ! currentStreamText &&
+ ! latestInProgressStatusVisible ? (
{ __( 'Thinking', 'clawpress' ) }
diff --git a/src/panel/components/PanelMessages.scss b/src/panel/components/PanelMessages.scss
index ce083a0..4f12172 100644
--- a/src/panel/components/PanelMessages.scss
+++ b/src/panel/components/PanelMessages.scss
@@ -159,16 +159,29 @@
content: '...';
display: inline-block;
margin-left: 2px;
- width: 1.2em;
+ width: 1ch;
white-space: nowrap;
overflow: hidden;
vertical-align: baseline;
line-height: 1;
- clip-path: inset(0 100% 0 0);
- animation: clawpress-thinking-dots 1.2s steps(3, end) infinite;
+ animation: clawpress-thinking-dots 1.2s steps(1, end) infinite;
}
@keyframes clawpress-thinking-dots {
- 0% { clip-path: inset(0 100% 0 0); }
- 100% { clip-path: inset(0 0 0 0); }
+ 0%,
+ 24.99% {
+ width: 1ch;
+ }
+ 25%,
+ 49.99% {
+ width: 2ch;
+ }
+ 50%,
+ 74.99% {
+ width: 3ch;
+ }
+ 75%,
+ 100% {
+ width: 1ch;
+ }
}
diff --git a/src/panel/services/realClient.js b/src/panel/services/realClient.js
index 812d41a..dfe5626 100644
--- a/src/panel/services/realClient.js
+++ b/src/panel/services/realClient.js
@@ -1,5 +1,32 @@
import { __, sprintf } from '@wordpress/i18n';
+const RUN_POLL_INTERVAL_MS = 1000;
+const RUN_POLL_MAX_SECONDS = 180;
+const RUN_PROGRESS_MESSAGE_STEP_SECONDS = 8;
+const RUN_PROGRESS_MESSAGES = [
+ __( 'I am still working on this', 'clawpress' ),
+ __( 'Yes, still working on it.', 'clawpress' ),
+ __( 'Ok, this is taking long now. Still on it.', 'clawpress' ),
+ __(
+ 'Plot twist: still working on it. My keyboard is sweating.',
+ 'clawpress'
+ ),
+ __(
+ 'At this point even my coffee is worried, but I am still on it.',
+ 'clawpress'
+ ),
+];
+const TERMINAL_RUN_STATUSES = new Set( [
+ 'done',
+ 'success',
+ 'error',
+ 'timeout',
+ 'requires_confirmation',
+ 'failed',
+ 'cancelled',
+ 'canceled',
+] );
+
const requestJson = async ( { url, method = 'GET', nonce, body, signal } ) => {
const res = await fetch( url, {
method,
@@ -38,6 +65,9 @@ const requestJson = async ( { url, method = 'GET', nonce, body, signal } ) => {
return payload;
};
+const isObjectRecord = ( value ) =>
+ Boolean( value ) && typeof value === 'object' && ! Array.isArray( value );
+
const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => {
const sendMessage = ( message, signal ) =>
requestJson( {
@@ -48,6 +78,22 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => {
signal,
} );
+ const getRunStatus = ( runId, signal ) =>
+ requestJson( {
+ url: `${ restBase }/agent/runs/${ runId }`,
+ method: 'GET',
+ nonce,
+ signal,
+ } );
+
+ const getRunEvents = ( runId, after, signal ) =>
+ requestJson( {
+ url: `${ restBase }/agent/runs/${ runId }/events?after=${ after }&limit=100`,
+ method: 'GET',
+ nonce,
+ signal,
+ } );
+
const getHistory = () =>
requestJson( {
url: `${ restBase }/chat/history`,
@@ -77,6 +123,39 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => {
body: state,
} );
+ const waitForNextPoll = ( signal ) =>
+ new Promise( ( resolve, reject ) => {
+ const timerId = setTimeout( () => {
+ signal?.removeEventListener( 'abort', onAbort );
+ resolve();
+ }, RUN_POLL_INTERVAL_MS );
+
+ const onAbort = () => {
+ clearTimeout( timerId );
+ signal?.removeEventListener( 'abort', onAbort );
+ const abortError = new Error( 'Aborted' );
+ abortError.name = 'AbortError';
+ reject( abortError );
+ };
+
+ signal?.addEventListener( 'abort', onAbort, { once: true } );
+ } );
+
+ const getRunProgressMessage = ( elapsedSeconds ) => {
+ const normalizedElapsedSeconds = Number.isFinite(
+ Number( elapsedSeconds )
+ )
+ ? Math.max( 0, Number( elapsedSeconds ) )
+ : 0;
+ const index = Math.min(
+ Math.floor(
+ normalizedElapsedSeconds / RUN_PROGRESS_MESSAGE_STEP_SECONDS
+ ),
+ RUN_PROGRESS_MESSAGES.length - 1
+ );
+ return RUN_PROGRESS_MESSAGES[ index ] || RUN_PROGRESS_MESSAGES[ 0 ];
+ };
+
const normalizeCard = ( rawCard ) => {
if ( ! rawCard || typeof rawCard !== 'object' ) {
return null;
@@ -221,9 +300,411 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => {
};
};
+ const normalizeRunStatus = ( value ) =>
+ typeof value === 'string' ? value.trim().toLowerCase() : '';
+
+ const buildToolCallDedupKey = ( call ) => {
+ if ( ! call || typeof call !== 'object' ) {
+ return '';
+ }
+
+ const name = typeof call.name === 'string' ? call.name.trim() : '';
+ if ( ! name ) {
+ return '';
+ }
+
+ const status =
+ typeof call.status === 'string'
+ ? call.status.trim().toLowerCase()
+ : '';
+ const message =
+ typeof call.message === 'string' ? call.message.trim() : '';
+ const round = Number.isFinite( Number( call.round ) )
+ ? Math.max( 1, Math.round( Number( call.round ) ) )
+ : 1;
+ const sequence = Number.isFinite( Number( call.sequence ) )
+ ? Math.max( 1, Math.round( Number( call.sequence ) ) )
+ : 1;
+
+ return `${ name }|${ status }|${ round }|${ sequence }|${ message }`;
+ };
+
+ const emitToolCallIfNew = ( call, index, total, seenToolCallKeys ) => {
+ const dedupKey = buildToolCallDedupKey( call );
+ if ( dedupKey && seenToolCallKeys instanceof Set ) {
+ if ( seenToolCallKeys.has( dedupKey ) ) {
+ return;
+ }
+ seenToolCallKeys.add( dedupKey );
+ }
+
+ onEvent( 'tool_call', {
+ call,
+ index,
+ total,
+ } );
+ };
+
+ const emitToolCallEvents = ( events, seenToolCallKeys ) => {
+ if ( ! Array.isArray( events ) || events.length === 0 ) {
+ return;
+ }
+
+ const canonicalToolEvents = events.filter(
+ ( event ) =>
+ isObjectRecord( event ) &&
+ typeof event.event_type === 'string' &&
+ event.event_type === 'agent.tool_call'
+ );
+ if ( canonicalToolEvents.length > 0 ) {
+ canonicalToolEvents.forEach( ( event, index ) => {
+ const payload = isObjectRecord( event.payload )
+ ? event.payload
+ : {};
+ const status = normalizeRunStatus( payload.status );
+
+ let toolName = 'tool_call';
+ if (
+ typeof payload.tool_name === 'string' &&
+ payload.tool_name.trim()
+ ) {
+ toolName = payload.tool_name.trim();
+ } else if (
+ typeof payload.ability_name === 'string' &&
+ payload.ability_name.trim()
+ ) {
+ toolName = payload.ability_name.trim();
+ }
+
+ let normalizedStatus = 'success';
+ if (
+ status === 'error' ||
+ status === 'requires_confirmation'
+ ) {
+ normalizedStatus = status;
+ }
+
+ const detailMessage =
+ typeof payload.message === 'string' &&
+ payload.message.trim()
+ ? payload.message.trim()
+ : '';
+
+ const call = {
+ name: toolName,
+ ability:
+ typeof payload.ability_name === 'string' &&
+ payload.ability_name.trim()
+ ? payload.ability_name.trim()
+ : null,
+ args: {},
+ status: normalizedStatus,
+ message: detailMessage || null,
+ round: Number.isFinite( Number( payload.round ) )
+ ? Math.max( 1, Math.round( Number( payload.round ) ) )
+ : 1,
+ sequence: Number.isFinite( Number( payload.sequence ) )
+ ? Math.max(
+ 1,
+ Math.round( Number( payload.sequence ) )
+ )
+ : index + 1,
+ requiresConfirmation: status === 'requires_confirmation',
+ };
+
+ emitToolCallIfNew(
+ call,
+ index + 1,
+ canonicalToolEvents.length,
+ seenToolCallKeys
+ );
+ } );
+ return;
+ }
+
+ // Fallback for older backends that only emit `tool_call` events.
+ const toolEvents = events.filter(
+ ( event ) =>
+ isObjectRecord( event ) &&
+ typeof event.event_type === 'string' &&
+ event.event_type === 'tool_call'
+ );
+ if ( toolEvents.length === 0 ) {
+ return;
+ }
+
+ toolEvents.forEach( ( event, index ) => {
+ const payload = isObjectRecord( event.payload )
+ ? event.payload
+ : {};
+ const status = normalizeRunStatus( payload.status );
+ const result = isObjectRecord( payload.result )
+ ? payload.result
+ : {};
+ const error = isObjectRecord( payload.error ) ? payload.error : {};
+ let detailMessage = '';
+ if ( typeof error.message === 'string' && error.message.trim() ) {
+ detailMessage = error.message.trim();
+ } else if (
+ typeof result.message === 'string' &&
+ result.message.trim()
+ ) {
+ detailMessage = result.message.trim();
+ }
+
+ let toolName = 'tool_call';
+ if (
+ typeof payload.tool_name === 'string' &&
+ payload.tool_name.trim()
+ ) {
+ toolName = payload.tool_name.trim();
+ } else if (
+ typeof payload.ability_name === 'string' &&
+ payload.ability_name.trim()
+ ) {
+ toolName = payload.ability_name.trim();
+ }
+
+ let normalizedStatus = 'success';
+ if ( status === 'error' || status === 'requires_confirmation' ) {
+ normalizedStatus = status;
+ }
+
+ const call = {
+ name: toolName,
+ ability:
+ typeof payload.ability_name === 'string' &&
+ payload.ability_name.trim()
+ ? payload.ability_name.trim()
+ : null,
+ args: {},
+ status: normalizedStatus,
+ message: detailMessage || null,
+ round: Number.isFinite( Number( payload.round ) )
+ ? Math.max( 1, Math.round( Number( payload.round ) ) )
+ : 1,
+ sequence: Number.isFinite( Number( payload.sequence ) )
+ ? Math.max( 1, Math.round( Number( payload.sequence ) ) )
+ : index + 1,
+ requiresConfirmation: status === 'requires_confirmation',
+ };
+
+ emitToolCallIfNew(
+ call,
+ index + 1,
+ toolEvents.length,
+ seenToolCallKeys
+ );
+ } );
+ };
+
+ const emitRuntimeResult = (
+ runtimeResult,
+ initialReply,
+ seenToolCallKeys
+ ) => {
+ if ( ! isObjectRecord( runtimeResult ) ) {
+ return false;
+ }
+
+ const contextUsage = normalizeContextUsage( runtimeResult.context );
+ if ( contextUsage ) {
+ onEvent( 'context_usage', { context: contextUsage } );
+ }
+
+ const toolCalls = Array.isArray( runtimeResult.tool_calls )
+ ? runtimeResult.tool_calls
+ .map( ( rawCall ) => normalizeToolCall( rawCall ) )
+ .filter( Boolean )
+ : [];
+ toolCalls.forEach( ( call, index ) => {
+ emitToolCallIfNew(
+ call,
+ index + 1,
+ toolCalls.length,
+ seenToolCallKeys
+ );
+ } );
+
+ if ( isObjectRecord( runtimeResult.error ) ) {
+ const runtimeError = runtimeResult.error;
+ const errorMessage =
+ typeof runtimeError.message === 'string' &&
+ runtimeError.message.trim()
+ ? runtimeError.message.trim()
+ : __( 'Run failed.', 'clawpress' );
+ onEvent( 'error', {
+ error: errorMessage,
+ type:
+ typeof runtimeError.type === 'string' &&
+ runtimeError.type.trim()
+ ? runtimeError.type.trim()
+ : 'provider',
+ card: normalizeCard( runtimeResult.card ),
+ } );
+ return true;
+ }
+
+ const reply =
+ typeof runtimeResult.assistant_text === 'string'
+ ? runtimeResult.assistant_text.trim()
+ : '';
+ const card = normalizeCard( runtimeResult.card );
+ if ( card ) {
+ onEvent( 'response_card', {
+ card,
+ text: reply,
+ role: 'assistant',
+ } );
+ return true;
+ }
+
+ if ( reply && reply !== initialReply ) {
+ onEvent( 'response_message', {
+ text: reply,
+ role: 'assistant',
+ } );
+ return true;
+ }
+
+ return false;
+ };
+
+ const emitRunTerminalOutcome = (
+ runPayload,
+ initialReply,
+ seenToolCallKeys
+ ) => {
+ const runMeta = isObjectRecord( runPayload?.meta )
+ ? runPayload.meta
+ : {};
+ let runtimeResult = null;
+ if ( isObjectRecord( runMeta.result ) ) {
+ runtimeResult = runMeta.result;
+ } else if ( isObjectRecord( runMeta.last_result ) ) {
+ runtimeResult = runMeta.last_result;
+ }
+
+ if (
+ emitRuntimeResult( runtimeResult, initialReply, seenToolCallKeys )
+ ) {
+ return;
+ }
+
+ const runStatus = normalizeRunStatus( runPayload?.status );
+
+ if ( runStatus === 'requires_confirmation' ) {
+ onEvent( 'response_message', {
+ text: __(
+ 'Action requires confirmation before continuing.',
+ 'clawpress'
+ ),
+ role: 'assistant',
+ } );
+ return;
+ }
+
+ if ( runStatus === 'done' || runStatus === 'success' ) {
+ onEvent( 'response_message', {
+ text: __(
+ 'I finished the background steps, but I did not receive a final text response. Please tell me to continue and I will pick up from here.',
+ 'clawpress'
+ ),
+ role: 'assistant',
+ } );
+ return;
+ }
+
+ const errorMessage =
+ typeof runPayload?.error_message === 'string' &&
+ runPayload.error_message.trim()
+ ? runPayload.error_message.trim()
+ : __( 'Run ended with an error.', 'clawpress' );
+
+ onEvent( 'error', {
+ error: errorMessage,
+ type: runStatus === 'timeout' ? 'timeout' : 'provider',
+ } );
+ };
+
+ const pollRunUntilTerminal = async ( {
+ runId,
+ initialReply,
+ signal,
+ initialEventsCursor = 0,
+ seenToolCallKeys = null,
+ } ) => {
+ let afterEventId = Number.isFinite( Number( initialEventsCursor ) )
+ ? Math.max( 0, Math.round( Number( initialEventsCursor ) ) )
+ : 0;
+ const startedAt = Date.now();
+ let lastProgressMessage = '';
+
+ while ( true ) {
+ if ( signal?.aborted ) {
+ const abortError = new Error( 'Aborted' );
+ abortError.name = 'AbortError';
+ throw abortError;
+ }
+
+ const elapsedSeconds = ( Date.now() - startedAt ) / 1000;
+ if ( elapsedSeconds >= RUN_POLL_MAX_SECONDS ) {
+ onEvent( 'error', {
+ error: __(
+ 'Run polling timed out before a terminal status was received.',
+ 'clawpress'
+ ),
+ type: 'timeout',
+ } );
+ return;
+ }
+
+ try {
+ const eventPayload = await getRunEvents(
+ runId,
+ afterEventId,
+ signal
+ );
+ const events = Array.isArray( eventPayload?.events )
+ ? eventPayload.events
+ : [];
+ emitToolCallEvents( events, seenToolCallKeys );
+
+ const nextCursor = Number( eventPayload?.next_cursor );
+ if ( Number.isFinite( nextCursor ) && nextCursor > 0 ) {
+ afterEventId = Math.max( afterEventId, nextCursor );
+ }
+ } catch ( err ) {
+ if ( err?.name === 'AbortError' ) {
+ throw err;
+ }
+ }
+
+ const runPayload = await getRunStatus( runId, signal );
+ const status = normalizeRunStatus( runPayload?.status );
+ if ( TERMINAL_RUN_STATUSES.has( status ) ) {
+ emitRunTerminalOutcome(
+ runPayload,
+ initialReply,
+ seenToolCallKeys
+ );
+ return;
+ }
+
+ const progressMessage = getRunProgressMessage( elapsedSeconds );
+ if ( progressMessage && progressMessage !== lastProgressMessage ) {
+ onEvent( 'run_progress', { text: progressMessage } );
+ lastProgressMessage = progressMessage;
+ }
+
+ await waitForNextPoll( signal );
+ }
+ };
+
// Keep a stream-compatible interface for the existing panel flow.
const stream = ( prompt ) => {
const controller = new AbortController();
+ const seenToolCallKeys = new Set();
( async () => {
try {
@@ -256,11 +737,12 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => {
: [];
toolCalls.forEach( ( call, index ) => {
- onEvent( 'tool_call', {
+ emitToolCallIfNew(
call,
- index: index + 1,
- total: toolCalls.length,
- } );
+ index + 1,
+ toolCalls.length,
+ seenToolCallKeys
+ );
} );
const responseError =
@@ -316,6 +798,31 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => {
} );
}
+ const runStatus = normalizeRunStatus( response?.meta?.status );
+ const runId = Number( response?.meta?.run_id );
+ const initialEventsCursor = Number(
+ response?.meta?.events_cursor
+ );
+ if ( runStatus === 'in_progress' ) {
+ if ( Number.isFinite( runId ) && runId > 0 ) {
+ await pollRunUntilTerminal( {
+ runId,
+ initialReply: reply,
+ signal: controller.signal,
+ initialEventsCursor,
+ seenToolCallKeys,
+ } );
+ } else {
+ onEvent( 'error', {
+ error: __(
+ 'Run entered progress mode without a valid run ID.',
+ 'clawpress'
+ ),
+ type: 'request',
+ } );
+ }
+ }
+
onDone?.( { aborted: false } );
} catch ( err ) {
if ( err?.name === 'AbortError' ) {
diff --git a/tests/Support/AgentRuntimeWpdb.php b/tests/Support/AgentRuntimeWpdb.php
new file mode 100644
index 0000000..be6640e
--- /dev/null
+++ b/tests/Support/AgentRuntimeWpdb.php
@@ -0,0 +1,463 @@
+> */
+ public array $sessions = [];
+
+ /** @var array> */
+ public array $runs = [];
+
+ /** @var array> */
+ public array $events = [];
+
+ /** @var array */
+ public array $last_prepare_args = [];
+
+ public bool $fail_session_update = false;
+
+ public bool $simulate_idempotency_race = false;
+
+ public string $last_error = '';
+
+ private bool $in_transaction = false;
+
+ /** @var array{sessions:array>,runs:array>,events:array>,insert_id:int}|null */
+ private ?array $transaction_snapshot = null;
+
+ public function get_charset_collate(): string {
+ return 'DEFAULT CHARSET=utf8mb4';
+ }
+
+ /**
+ * @param string $table Table name.
+ * @param array $data Data payload.
+ * @param array $format Format list.
+ * @return int|false
+ */
+ public function insert( string $table, array $data, array $format ) {
+ unset( $format );
+ $this->last_error = '';
+
+ if ( false !== strpos( $table, 'agent_runs' ) ) {
+ $idempotency_key = isset( $data['idempotency_key'] ) ? trim( (string) $data['idempotency_key'] ) : '';
+ $session_id = isset( $data['session_id'] ) ? (int) $data['session_id'] : 0;
+ if ( $session_id > 0 && '' !== $idempotency_key ) {
+ $existing_run = $this->find_run_by_idempotency_key( $session_id, $idempotency_key );
+ if ( null === $existing_run && $this->simulate_idempotency_race ) {
+ $this->simulate_idempotency_race = false;
+ ++$this->insert_id;
+ $data['id'] = $this->insert_id;
+ $this->runs[ $this->insert_id ] = $data;
+ $this->last_error = 'Duplicate entry for key session_idempotency_key';
+ return false;
+ }
+
+ if ( null !== $existing_run ) {
+ $this->last_error = 'Duplicate entry for key session_idempotency_key';
+ return false;
+ }
+ }
+ }
+
+ ++$this->insert_id;
+ $data['id'] = $this->insert_id;
+
+ if ( false !== strpos( $table, 'agent_sessions' ) ) {
+ $this->sessions[ $this->insert_id ] = $data;
+ return 1;
+ }
+
+ if ( false !== strpos( $table, 'agent_runs' ) ) {
+ $this->runs[ $this->insert_id ] = $data;
+ return 1;
+ }
+
+ if ( false !== strpos( $table, 'agent_events' ) ) {
+ $this->events[ $this->insert_id ] = $data;
+ return 1;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $table Table name.
+ * @param array $data Data payload.
+ * @param array $where Where payload.
+ * @param array|null $format Formats.
+ * @param array|null $where_format Where formats.
+ * @return int|false
+ */
+ public function update( string $table, array $data, array $where, ?array $format = null, ?array $where_format = null ) {
+ unset( $format, $where_format );
+
+ $target = false !== strpos( $table, 'agent_sessions' ) ? 'sessions' : ( false !== strpos( $table, 'agent_runs' ) ? 'runs' : '' );
+ if ( '' === $target ) {
+ return false;
+ }
+
+ if ( $this->fail_session_update && 'sessions' === $target ) {
+ return false;
+ }
+
+ $updated = 0;
+ foreach ( $this->{$target} as $id => $row ) {
+ $matches = true;
+ foreach ( $where as $key => $value ) {
+ $current = $row[ $key ] ?? null;
+ if ( (string) $current !== (string) $value ) {
+ $matches = false;
+ break;
+ }
+ }
+
+ if ( ! $matches ) {
+ continue;
+ }
+
+ $this->{$target}[ $id ] = array_merge( $row, $data );
+ ++$updated;
+ }
+
+ return $updated;
+ }
+
+ /**
+ * @return int|false
+ */
+ public function query( string $sql ) {
+ $sql_upper = strtoupper( trim( $sql ) );
+
+ if ( 'START TRANSACTION' === $sql_upper ) {
+ $this->in_transaction = true;
+ $this->transaction_snapshot = [
+ 'sessions' => $this->sessions,
+ 'runs' => $this->runs,
+ 'events' => $this->events,
+ 'insert_id' => $this->insert_id,
+ ];
+ return 1;
+ }
+
+ if ( 'COMMIT' === $sql_upper ) {
+ $this->in_transaction = false;
+ $this->transaction_snapshot = null;
+ return 1;
+ }
+
+ if ( 'ROLLBACK' === $sql_upper ) {
+ if ( $this->in_transaction && is_array( $this->transaction_snapshot ) ) {
+ $this->sessions = $this->transaction_snapshot['sessions'];
+ $this->runs = $this->transaction_snapshot['runs'];
+ $this->events = $this->transaction_snapshot['events'];
+ $this->insert_id = $this->transaction_snapshot['insert_id'];
+ }
+
+ $this->in_transaction = false;
+ $this->transaction_snapshot = null;
+ return 1;
+ }
+
+ if ( str_starts_with( $sql_upper, 'UPDATE' ) && false !== strpos( $sql_upper, 'AGENT_SESSIONS' ) ) {
+ if ( $this->fail_session_update ) {
+ return false;
+ }
+
+ $has_paused_clause = count( $this->last_prepare_args ) >= 7;
+ $session_id_index = $has_paused_clause ? 6 : 5;
+ $next_run_index = $has_paused_clause ? 4 : 3;
+ $updated_index = $has_paused_clause ? 5 : 4;
+ $session_id = isset( $this->last_prepare_args[ $session_id_index ] ) ? (int) $this->last_prepare_args[ $session_id_index ] : 0;
+ if ( $session_id <= 0 || ! isset( $this->sessions[ $session_id ] ) ) {
+ return 0;
+ }
+
+ $run_status = isset( $this->last_prepare_args[1] ) ? (string) $this->last_prepare_args[1] : '';
+ $failures = (int) ( $this->sessions[ $session_id ]['consecutive_failures'] ?? 0 );
+
+ if ( in_array( $run_status, [ 'success', 'done', 'requires_confirmation' ], true ) ) {
+ $failures = 0;
+ } elseif ( 'paused' !== $run_status ) {
+ ++$failures;
+ }
+
+ $this->sessions[ $session_id ]['last_run_at_gmt'] = isset( $this->last_prepare_args[0] ) ? (string) $this->last_prepare_args[0] : null;
+ $this->sessions[ $session_id ]['last_run_status'] = $run_status;
+ $this->sessions[ $session_id ]['consecutive_failures'] = $failures;
+ $this->sessions[ $session_id ]['next_run_at_gmt'] = $this->last_prepare_args[ $next_run_index ] ?? null;
+ $this->sessions[ $session_id ]['updated_at_gmt'] = isset( $this->last_prepare_args[ $updated_index ] ) ? (string) $this->last_prepare_args[ $updated_index ] : null;
+ return 1;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $query Query string.
+ * @param array|mixed ...$args Prepare args.
+ */
+ public function prepare( string $query, ...$args ): string {
+ if ( 1 === count( $args ) && is_array( $args[0] ) ) {
+ $args = $args[0];
+ }
+
+ $prepared_query = $query;
+ $non_identifier_arguments = [];
+
+ foreach ( $args as $argument ) {
+ if ( false !== strpos( $prepared_query, '%i' ) ) {
+ $identifier = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $argument );
+ $prepared_query = preg_replace( '/%i/', (string) $identifier, $prepared_query, 1 ) ?? $prepared_query;
+ continue;
+ }
+
+ $non_identifier_arguments[] = $argument;
+ }
+
+ $this->last_prepare_args = $non_identifier_arguments;
+ return $prepared_query;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function get_row( string $query, string $output ) {
+ unset( $output );
+
+ if ( false !== strpos( $query, 'idempotency_key = %s' ) ) {
+ $session_id = isset( $this->last_prepare_args[0] ) ? (int) $this->last_prepare_args[0] : 0;
+ $idempotency_key = isset( $this->last_prepare_args[1] ) ? (string) $this->last_prepare_args[1] : '';
+ if ( $session_id <= 0 || '' === $idempotency_key ) {
+ return null;
+ }
+
+ $matching = array_values(
+ array_filter(
+ $this->runs,
+ static fn( array $row ): bool => (int) ( $row['session_id'] ?? 0 ) === $session_id
+ && (string) ( $row['idempotency_key'] ?? '' ) === $idempotency_key
+ )
+ );
+
+ if ( [] === $matching ) {
+ return null;
+ }
+
+ usort(
+ $matching,
+ static fn( array $left, array $right ): int => (int) ( $right['id'] ?? 0 ) <=> (int) ( $left['id'] ?? 0 )
+ );
+
+ return $matching[0];
+ }
+
+ $id = isset( $this->last_prepare_args[0] ) ? (int) $this->last_prepare_args[0] : 0;
+ if ( $id <= 0 ) {
+ return null;
+ }
+
+ if ( false !== strpos( $query, 'agent_sessions' ) ) {
+ return $this->sessions[ $id ] ?? null;
+ }
+
+ if ( false !== strpos( $query, 'agent_runs' ) ) {
+ return $this->runs[ $id ] ?? null;
+ }
+
+ if ( false !== strpos( $query, 'agent_events' ) ) {
+ return $this->events[ $id ] ?? null;
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array>
+ */
+ public function get_results( string $query, string $output ): array {
+ unset( $output );
+
+ if ( false !== strpos( $query, 'agent_runs' ) ) {
+ return $this->get_runnable_runs();
+ }
+
+ if ( false !== strpos( $query, 'agent_events' ) ) {
+ return $this->get_events_rows( $query );
+ }
+
+ return [];
+ }
+
+ /**
+ * @return array>
+ */
+ private function get_runnable_runs(): array {
+ $now_gmt = isset( $this->last_prepare_args[0] ) ? (string) $this->last_prepare_args[0] : gmdate( 'Y-m-d H:i:s' );
+ $limit = isset( $this->last_prepare_args[2] ) ? (int) $this->last_prepare_args[2] : ( isset( $this->last_prepare_args[1] ) ? (int) $this->last_prepare_args[1] : 20 );
+ $limit = max( 1, $limit );
+ $now_ts = strtotime( $now_gmt );
+ if ( false === $now_ts ) {
+ $now_ts = time();
+ }
+
+ $rows = array_values(
+ array_filter(
+ $this->runs,
+ static function ( array $row ) use ( $now_ts ): bool {
+ $status = isset( $row['status'] ) ? (string) $row['status'] : '';
+ if ( in_array( $status, [ 'queued', 'paused' ], true ) ) {
+ $retry_at = isset( $row['next_retry_at_gmt'] ) ? (string) $row['next_retry_at_gmt'] : '';
+ if ( '' === $retry_at ) {
+ return true;
+ }
+
+ $retry_ts = strtotime( $retry_at );
+ if ( false === $retry_ts ) {
+ return true;
+ }
+
+ return $retry_ts <= $now_ts;
+ }
+
+ if ( 'running' === $status ) {
+ $lock_expires_at = isset( $row['lock_expires_at_gmt'] ) ? (string) $row['lock_expires_at_gmt'] : '';
+ if ( '' === $lock_expires_at ) {
+ return false;
+ }
+
+ $lock_expires_ts = strtotime( $lock_expires_at );
+ if ( false === $lock_expires_ts ) {
+ return false;
+ }
+
+ return $lock_expires_ts <= $now_ts;
+ }
+
+ return false;
+ }
+ )
+ );
+
+ usort(
+ $rows,
+ static function ( array $left, array $right ): int {
+ $left_ts = strtotime( (string) ( $left['created_at_gmt'] ?? '' ) ) ?: 0;
+ $right_ts = strtotime( (string) ( $right['created_at_gmt'] ?? '' ) ) ?: 0;
+ if ( $left_ts === $right_ts ) {
+ return (int) ( $left['id'] ?? 0 ) <=> (int) ( $right['id'] ?? 0 );
+ }
+
+ return $left_ts <=> $right_ts;
+ }
+ );
+
+ return array_slice( $rows, 0, $limit );
+ }
+
+ /**
+ * @return array>
+ */
+ private function get_events_rows( string $query ): array {
+ $arg_index = 0;
+ $after_id = isset( $this->last_prepare_args[ $arg_index ] ) ? (int) $this->last_prepare_args[ $arg_index ] : 0;
+ ++$arg_index;
+
+ $run_id = null;
+ if ( false !== strpos( $query, 'run_id = %d' ) ) {
+ $run_id = isset( $this->last_prepare_args[ $arg_index ] ) ? (int) $this->last_prepare_args[ $arg_index ] : null;
+ ++$arg_index;
+ }
+
+ $session_id = null;
+ if ( false !== strpos( $query, 'session_id = %d' ) ) {
+ $session_id = isset( $this->last_prepare_args[ $arg_index ] ) ? (int) $this->last_prepare_args[ $arg_index ] : null;
+ ++$arg_index;
+ }
+
+ $event_type = null;
+ if ( false !== strpos( $query, 'event_type = %s' ) ) {
+ $event_type = isset( $this->last_prepare_args[ $arg_index ] ) ? (string) $this->last_prepare_args[ $arg_index ] : null;
+ ++$arg_index;
+ }
+
+ $limit = isset( $this->last_prepare_args[ $arg_index ] ) ? (int) $this->last_prepare_args[ $arg_index ] : 100;
+ $limit = max( 1, $limit );
+
+ $rows = array_values(
+ array_filter(
+ $this->events,
+ static function ( array $row ) use ( $after_id, $run_id, $session_id, $event_type ): bool {
+ $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
+ if ( $id <= $after_id ) {
+ return false;
+ }
+
+ if ( null !== $run_id && (int) ( $row['run_id'] ?? 0 ) !== $run_id ) {
+ return false;
+ }
+
+ if ( null !== $session_id && (int) ( $row['session_id'] ?? 0 ) !== $session_id ) {
+ return false;
+ }
+
+ if ( null !== $event_type && (string) ( $row['event_type'] ?? '' ) !== $event_type ) {
+ return false;
+ }
+
+ return true;
+ }
+ )
+ );
+
+ usort(
+ $rows,
+ static fn( array $left, array $right ): int => (int) ( $left['id'] ?? 0 ) <=> (int) ( $right['id'] ?? 0 )
+ );
+
+ return array_slice( $rows, 0, $limit );
+ }
+
+ /**
+ * Find run by session + idempotency key.
+ *
+ * @param int $session_id Session identifier.
+ * @param string $idempotency_key Idempotency key.
+ * @return array|null
+ */
+ private function find_run_by_idempotency_key( int $session_id, string $idempotency_key ): ?array {
+ $matching = array_values(
+ array_filter(
+ $this->runs,
+ static fn( array $row ): bool => (int) ( $row['session_id'] ?? 0 ) === $session_id
+ && (string) ( $row['idempotency_key'] ?? '' ) === $idempotency_key
+ )
+ );
+
+ if ( [] === $matching ) {
+ return null;
+ }
+
+ usort(
+ $matching,
+ static fn( array $left, array $right ): int => (int) ( $right['id'] ?? 0 ) <=> (int) ( $left['id'] ?? 0 )
+ );
+
+ return $matching[0];
+ }
+}
diff --git a/tests/Support/WordPressStubs.php b/tests/Support/WordPressStubs.php
index 8afddcf..cd98748 100644
--- a/tests/Support/WordPressStubs.php
+++ b/tests/Support/WordPressStubs.php
@@ -52,6 +52,12 @@ final class WordPress_Stubs {
/** @var array> */
public static array $scheduled_actions = array();
+ /** @var array> */
+ public static array $single_scheduled_actions = array();
+
+ /** @var array> */
+ public static array $async_actions = array();
+
/** @var array> */
public static array $triggered_actions = array();
@@ -71,6 +77,9 @@ final class WordPress_Stubs {
public static bool $can_manage_options = true;
+ /** @var array> */
+ public static array $user_capabilities = array();
+
public static bool $is_rtl = false;
public static bool $has_scheduled_action = false;
@@ -98,6 +107,8 @@ public static function reset(): void {
self::$abilities = array();
self::$ability_categories = array();
self::$scheduled_actions = array();
+ self::$single_scheduled_actions = array();
+ self::$async_actions = array();
self::$triggered_actions = array();
self::$options = array();
self::$user_meta = array();
@@ -105,6 +116,7 @@ public static function reset(): void {
self::$post_meta = array();
self::$next_post_id = 1;
self::$can_manage_options = true;
+ self::$user_capabilities = array();
self::$is_rtl = false;
self::$has_scheduled_action = false;
self::$current_user_id = 1;
@@ -218,6 +230,13 @@ function sanitize_title( string $title ): string {
}
}
+ if ( ! function_exists( 'sanitize_key' ) ) {
+ function sanitize_key( string $key ): string {
+ $key = strtolower( trim( $key ) );
+ return (string) preg_replace( '/[^a-z0-9_\-]/', '', $key );
+ }
+ }
+
if ( ! function_exists( 'wp_json_encode' ) ) {
function wp_json_encode( $value, int $flags = 0, int $depth = 512 ) {
return json_encode( $value, $flags, $depth );
@@ -315,6 +334,11 @@ function wp_localize_script( $handle, $object_name, $l10n ): bool {
if ( ! function_exists( 'current_user_can' ) ) {
function current_user_can( string $capability ): bool {
+ $current_user_id = WordPress_Stubs::$current_user_id;
+ if ( isset( WordPress_Stubs::$user_capabilities[ $current_user_id ][ $capability ] ) ) {
+ return true === WordPress_Stubs::$user_capabilities[ $current_user_id ][ $capability ];
+ }
+
if ( 'manage_options' === $capability ) {
return WordPress_Stubs::$can_manage_options;
}
@@ -325,7 +349,10 @@ function current_user_can( string $capability ): bool {
if ( ! function_exists( 'user_can' ) ) {
function user_can( int $user_id, string $capability ): bool {
- unset( $user_id );
+ if ( isset( WordPress_Stubs::$user_capabilities[ $user_id ][ $capability ] ) ) {
+ return true === WordPress_Stubs::$user_capabilities[ $user_id ][ $capability ];
+ }
+
return current_user_can( $capability );
}
}
@@ -641,6 +668,31 @@ function as_schedule_recurring_action( int $timestamp, int $interval_in_seconds,
}
}
+ if ( ! function_exists( 'as_schedule_single_action' ) ) {
+ function as_schedule_single_action( int $timestamp, string $hook, array $args = array(), string $group = '', bool $unique = false, int $priority = 10 ): int {
+ unset( $unique, $priority );
+ WordPress_Stubs::$single_scheduled_actions[] = array(
+ 'timestamp' => $timestamp,
+ 'hook' => $hook,
+ 'args' => $args,
+ 'group' => $group,
+ );
+ return 1;
+ }
+ }
+
+ if ( ! function_exists( 'as_enqueue_async_action' ) ) {
+ function as_enqueue_async_action( string $hook, array $args = array(), string $group = '', bool $unique = false, int $priority = 10 ): int {
+ unset( $unique, $priority );
+ WordPress_Stubs::$async_actions[] = array(
+ 'hook' => $hook,
+ 'args' => $args,
+ 'group' => $group,
+ );
+ return 1;
+ }
+ }
+
if ( ! function_exists( 'is_rtl' ) ) {
function is_rtl(): bool {
return WordPress_Stubs::$is_rtl;
diff --git a/tests/Unit/AbilitiesHelperTest.php b/tests/Unit/AbilitiesHelperTest.php
index 8cb8569..0d4f253 100644
--- a/tests/Unit/AbilitiesHelperTest.php
+++ b/tests/Unit/AbilitiesHelperTest.php
@@ -154,6 +154,60 @@ public function test_runtime_policy_can_enforce_file_delete_gate_for_spawned_age
$this->assertSame( 'spawned_agent', $result['policy']['trigger_type'] );
}
+ public function test_policy_violation_degrade_mode_returns_successful_degraded_result(): void {
+ $result = Abilities_Helper::get_instance()->execute_tool_call(
+ 'file_delete',
+ [
+ 'path' => 'notes.md',
+ ],
+ [
+ 'requesting_user_id' => 1,
+ 'execution_user_id' => 1,
+ 'runtime_policy' => [
+ 'trigger_type' => 'spawned_agent',
+ 'policy_profile' => 'default',
+ 'allow_tools' => true,
+ 'allow_destructive_tools' => true,
+ 'require_confirmation_for_destructive' => true,
+ 'allow_file_delete' => false,
+ 'on_policy_violation' => 'degrade',
+ ],
+ ]
+ );
+
+ $this->assertTrue( $result['success'] );
+ $this->assertSame( true, $result['degraded'] );
+ $this->assertArrayNotHasKey( 'error', $result );
+ $this->assertSame( 'degrade', $result['policy']['on_violation'] );
+ $this->assertSame( 'deny_file_delete', $result['policy']['decision'] );
+ }
+
+ public function test_policy_violation_fail_mode_returns_error_payload(): void {
+ $result = Abilities_Helper::get_instance()->execute_tool_call(
+ 'file_delete',
+ [
+ 'path' => 'notes.md',
+ ],
+ [
+ 'requesting_user_id' => 1,
+ 'execution_user_id' => 1,
+ 'runtime_policy' => [
+ 'trigger_type' => 'spawned_agent',
+ 'policy_profile' => 'default',
+ 'allow_tools' => true,
+ 'allow_destructive_tools' => true,
+ 'require_confirmation_for_destructive' => true,
+ 'allow_file_delete' => false,
+ 'on_policy_violation' => 'fail',
+ ],
+ ]
+ );
+
+ $this->assertFalse( $result['success'] );
+ $this->assertSame( 'clawpress_policy_file_delete_denied_fail', $result['error']['code'] );
+ $this->assertSame( 'fail', $result['policy']['on_violation'] );
+ }
+
public function test_destructive_confirmation_token_must_be_allowlisted_by_execution_context(): void {
$initial = Abilities_Helper::get_instance()->execute_tool_call(
'file_delete',
diff --git a/tests/Unit/ActionLogHelperTest.php b/tests/Unit/ActionLogHelperTest.php
index 46dba9e..063711c 100644
--- a/tests/Unit/ActionLogHelperTest.php
+++ b/tests/Unit/ActionLogHelperTest.php
@@ -101,8 +101,21 @@ public function prepare( string $query, ...$args ): string {
$args = $args[0];
}
- $this->last_prepare_args = $args;
- return $query;
+ $prepared_query = $query;
+ $non_identifier_arguments = [];
+
+ foreach ( $args as $argument ) {
+ if ( false !== strpos( $prepared_query, '%i' ) ) {
+ $identifier = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $argument );
+ $prepared_query = preg_replace( '/%i/', (string) $identifier, $prepared_query, 1 ) ?? $prepared_query;
+ continue;
+ }
+
+ $non_identifier_arguments[] = $argument;
+ }
+
+ $this->last_prepare_args = $non_identifier_arguments;
+ return $prepared_query;
}
/**
diff --git a/tests/Unit/AgentEventHelperTest.php b/tests/Unit/AgentEventHelperTest.php
index ac81c71..185c3e9 100644
--- a/tests/Unit/AgentEventHelperTest.php
+++ b/tests/Unit/AgentEventHelperTest.php
@@ -106,8 +106,21 @@ public function prepare( string $query, ...$args ): string {
$args = $args[0];
}
- $this->last_prepare_args = $args;
- return $query;
+ $prepared_query = $query;
+ $non_identifier_arguments = [];
+
+ foreach ( $args as $argument ) {
+ if ( false !== strpos( $prepared_query, '%i' ) ) {
+ $identifier = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $argument );
+ $prepared_query = preg_replace( '/%i/', (string) $identifier, $prepared_query, 1 ) ?? $prepared_query;
+ continue;
+ }
+
+ $non_identifier_arguments[] = $argument;
+ }
+
+ $this->last_prepare_args = $non_identifier_arguments;
+ return $prepared_query;
}
/**
diff --git a/tests/Unit/AgentLoopHelperTest.php b/tests/Unit/AgentLoopHelperTest.php
new file mode 100644
index 0000000..9c7cdc5
--- /dev/null
+++ b/tests/Unit/AgentLoopHelperTest.php
@@ -0,0 +1,185 @@
+ */
+ public array $captured = [];
+
+ /**
+ * @param array $context Runtime context.
+ * @return array
+ */
+ public function __invoke( array $context, string $provider, string $model ): array {
+ $this->captured = [
+ 'context' => $context,
+ 'provider' => $provider,
+ 'model' => $model,
+ ];
+
+ return [
+ 'reply' => 'Invokable generator reply.',
+ ];
+ }
+ };
+
+ $result = Agent_Loop_Helper::get_instance()->run_turn(
+ [
+ 'message' => 'Hello loop',
+ 'trigger' => 'chat',
+ 'transport_mode' => 'polling',
+ 'requesting_user_id' => 1,
+ 'execution_user_id' => 1,
+ 'provider_model_resolver' => static fn( array $settings ): array => [
+ 'provider' => 'openai',
+ 'model' => 'gpt-4.1-mini',
+ ],
+ 'online_reply_generator' => $invokable,
+ ]
+ );
+
+ $this->assertSame( 'success', $result['status'] );
+ $this->assertSame( 'online', $result['mode'] );
+ $this->assertSame( 'Invokable generator reply.', $result['assistant_text'] );
+ $this->assertSame( 'openai', $invokable->captured['provider'] );
+ $this->assertSame( 'gpt-4.1-mini', $invokable->captured['model'] );
+ $this->assertSame( 'Hello loop', $invokable->captured['context']['message'] );
+ }
+
+ public function test_run_turn_returns_offline_mode_when_provider_is_missing(): void {
+ $result = Agent_Loop_Helper::get_instance()->run_turn(
+ [
+ 'message' => 'Offline check',
+ 'provider_model_resolver' => static fn( array $settings ): array => [
+ 'provider' => '',
+ 'model' => '',
+ ],
+ ]
+ );
+
+ $this->assertSame( 'success', $result['status'] );
+ $this->assertSame( 'offline', $result['mode'] );
+ $this->assertSame( '', $result['assistant_text'] );
+ }
+
+ public function test_run_turn_streaming_mode_uses_polling_transport_events_cursor(): void {
+ $result = Agent_Loop_Helper::get_instance()->run_turn(
+ [
+ 'run_id' => 55,
+ 'session_id' => 77,
+ 'message' => 'Streaming mode',
+ 'transport_mode' => 'streaming',
+ 'provider_model_resolver' => static fn( array $settings ): array => [
+ 'provider' => 'openai',
+ 'model' => 'gpt-4.1-mini',
+ ],
+ 'online_reply_generator' => static function (): array {
+ return [
+ 'status' => 'success',
+ 'next_action' => 'stop',
+ 'reply' => 'Streaming fallback reply.',
+ ];
+ },
+ ]
+ );
+
+ $this->assertSame( 'success', $result['status'] );
+ $this->assertSame( 'Streaming fallback reply.', $result['assistant_text'] );
+ $this->assertArrayHasKey( 'events_cursor', $result );
+ $this->assertGreaterThan( 0, (int) $result['events_cursor'] );
+ $this->assertNotEmpty( $GLOBALS['wpdb']->events );
+ }
+
+ public function test_run_turn_fills_terminal_empty_assistant_text_with_friendly_message(): void {
+ $result = Agent_Loop_Helper::get_instance()->run_turn(
+ [
+ 'message' => 'Summarize files',
+ 'trigger' => 'chat',
+ 'transport_mode' => 'polling',
+ 'requesting_user_id' => 1,
+ 'execution_user_id' => 1,
+ 'provider_model_resolver' => static fn( array $settings ): array => [
+ 'provider' => 'openai',
+ 'model' => 'gpt-4.1-mini',
+ ],
+ 'online_reply_generator' => static function (): array {
+ return [
+ 'status' => 'success',
+ 'next_action' => 'stop',
+ 'reply' => '',
+ 'tool_calls' => [
+ [
+ 'name' => 'file_read',
+ 'status' => 'success',
+ 'round' => 1,
+ 'sequence' => 1,
+ ],
+ ],
+ ];
+ },
+ ]
+ );
+
+ $this->assertSame( 'success', $result['status'] );
+ $this->assertSame(
+ 'I finished the background steps, but I did not receive a final text response. Please tell me to continue and I will pick up from here.',
+ $result['assistant_text']
+ );
+ }
+
+ public function test_run_slice_returns_in_progress_with_resume_cursor(): void {
+ $result = Agent_Loop_Helper::get_instance()->run_slice(
+ [
+ 'run_id' => 100,
+ 'session_id' => 200,
+ 'message' => 'Slice budget test',
+ 'transport_mode' => 'polling',
+ 'slice_budget_ms' => 1,
+ 'max_steps_per_slice' => 1,
+ 'provider_model_resolver' => static fn( array $settings ): array => [
+ 'provider' => 'openai',
+ 'model' => 'gpt-4.1-mini',
+ ],
+ 'online_reply_generator' => static function (): array {
+ return [
+ 'status' => 'in_progress',
+ 'next_action' => 'continue_later',
+ 'reply' => 'Paused slice',
+ 'resume_cursor' => [
+ 'version' => 1,
+ 'round' => 1,
+ ],
+ ];
+ },
+ ]
+ );
+
+ $this->assertSame( 'in_progress', $result['status'] );
+ $this->assertSame( 'continue_later', $result['next_action'] );
+ $this->assertIsArray( $result['resume_cursor'] );
+ $this->assertSame( 1, $result['resume_cursor']['version'] );
+ }
+}
diff --git a/tests/Unit/AgentRunControllerTest.php b/tests/Unit/AgentRunControllerTest.php
new file mode 100644
index 0000000..eca6c78
--- /dev/null
+++ b/tests/Unit/AgentRunControllerTest.php
@@ -0,0 +1,235 @@
+create_run(
+ new \WP_REST_Request(
+ [
+ 'trigger' => 'chat',
+ 'message' => 'Process this run',
+ 'transport_mode' => 'polling',
+ 'max_attempts' => 6,
+ 'slice_budget_ms' => 2000,
+ 'max_steps_per_slice' => 2,
+ ]
+ )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 201, $response->get_status() );
+ $this->assertGreaterThan( 0, (int) $data['session_id'] );
+ $this->assertGreaterThan( 0, (int) $data['run_id'] );
+ $this->assertSame( 'queued', $data['status'] );
+ $this->assertCount( 1, WordPress_Stubs::$async_actions );
+ $this->assertSame( Agent_Runner::RUN_SLICE_ACTION_HOOK, WordPress_Stubs::$async_actions[0]['hook'] );
+ }
+
+ public function test_create_run_uses_configured_agent_user_for_execution_identity(): void {
+ update_option(
+ 'clawpress_settings',
+ [
+ 'agent_user_id' => 9,
+ ]
+ );
+
+ $controller = new Agent_Run_Controller();
+ $response = $controller->create_run(
+ new \WP_REST_Request(
+ [
+ 'message' => 'Execution user test',
+ ]
+ )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 201, $response->get_status() );
+ $this->assertSame( 9, (int) $GLOBALS['wpdb']->sessions[ (int) $data['session_id'] ]['execution_user_id'] );
+ }
+
+ public function test_enqueue_run_rejects_non_terminal_status(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
+ $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-running', 120 );
+ $this->assertTrue( $claim['claimed'] );
+
+ $controller = new Agent_Run_Controller();
+ $response = $controller->enqueue_run( new \WP_REST_Request( [ 'run_id' => $run_id ] ) );
+
+ $data = $response->get_data();
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'Run is not in a retryable state.', $data['error'] );
+ $this->assertCount( 0, WordPress_Stubs::$async_actions );
+ }
+
+ public function test_get_run_events_returns_incremental_cursor(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
+ $event_a = Agent_Event_Helper::get_instance()->emit(
+ 'agent.test.a',
+ [
+ 'run_id' => $run_id,
+ 'payload' => [ 'step' => 1 ],
+ ]
+ );
+ $event_b = Agent_Event_Helper::get_instance()->emit(
+ 'agent.test.b',
+ [
+ 'run_id' => $run_id,
+ 'payload' => [ 'step' => 2 ],
+ ]
+ );
+
+ $controller = new Agent_Run_Controller();
+ $response = $controller->get_run_events(
+ new \WP_REST_Request(
+ [
+ 'run_id' => $run_id,
+ 'after' => 0,
+ 'limit' => 50,
+ ]
+ )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( $run_id, $data['run_id'] );
+ $this->assertCount( 2, $data['events'] );
+ $this->assertSame( $event_a, $data['events'][0]['event_id'] );
+ $this->assertSame( $event_b, $data['next_cursor'] );
+ }
+
+ public function test_create_run_rejects_unknown_session_id(): void {
+ $controller = new Agent_Run_Controller();
+ $response = $controller->create_run(
+ new \WP_REST_Request(
+ [
+ 'session_id' => 9999,
+ 'message' => 'Invalid session test',
+ ]
+ )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'Session not found.', $data['error'] );
+ }
+
+ public function test_create_run_reuses_idempotency_key_for_same_session(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session(
+ [
+ 'requesting_user_id' => 1,
+ 'execution_user_id' => 1,
+ ]
+ );
+ $controller = new Agent_Run_Controller();
+ $first_reply = $controller->create_run(
+ new \WP_REST_Request(
+ [
+ 'session_id' => $session_id,
+ 'message' => 'Idempotency request',
+ 'idempotency_key' => 'same-key-123',
+ 'transport_mode' => 'polling',
+ 'max_attempts' => 4,
+ ]
+ )
+ );
+ $second_reply = $controller->create_run(
+ new \WP_REST_Request(
+ [
+ 'session_id' => $session_id,
+ 'message' => 'Idempotency request duplicate',
+ 'idempotency_key' => 'same-key-123',
+ ]
+ )
+ );
+
+ $first_data = $first_reply->get_data();
+ $second_data = $second_reply->get_data();
+
+ $this->assertSame( 201, $first_reply->get_status() );
+ $this->assertSame( 200, $second_reply->get_status() );
+ $this->assertSame( $first_data['run_id'], $second_data['run_id'] );
+ $this->assertCount( 1, $GLOBALS['wpdb']->runs );
+ $this->assertCount( 1, WordPress_Stubs::$async_actions );
+ }
+
+ public function test_controller_exposes_spawn_endpoint_method(): void {
+ $this->assertTrue( method_exists( Agent_Run_Controller::class, 'spawn_agent' ) );
+ }
+
+ public function test_spawn_agent_creates_spawned_session_and_run(): void {
+ $controller = new Agent_Run_Controller();
+ $response = $controller->spawn_agent(
+ new \WP_REST_Request(
+ [
+ 'message' => 'Run spawned workflow',
+ 'transport_mode' => 'polling',
+ 'slice_budget_ms' => 1200,
+ 'max_steps_per_slice' => 2,
+ ]
+ )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 201, $response->get_status() );
+ $this->assertSame( 'spawned_agent', $data['trigger'] );
+ $this->assertSame( 'queued', $data['status'] );
+ $this->assertGreaterThan( 0, (int) $data['session_id'] );
+ $this->assertGreaterThan( 0, (int) $data['run_id'] );
+ $this->assertCount( 1, WordPress_Stubs::$async_actions );
+ $this->assertSame( Agent_Runner::RUN_SLICE_ACTION_HOOK, WordPress_Stubs::$async_actions[0]['hook'] );
+ $this->assertSame( 'spawned_agent', $GLOBALS['wpdb']->sessions[ (int) $data['session_id'] ]['trigger_type'] );
+ $this->assertSame( 'spawned_agent', $GLOBALS['wpdb']->runs[ (int) $data['run_id'] ]['trigger_type'] );
+ }
+
+ public function test_spawn_agent_uses_configured_agent_user_for_execution_identity(): void {
+ update_option(
+ 'clawpress_settings',
+ [
+ 'agent_user_id' => 11,
+ ]
+ );
+
+ $controller = new Agent_Run_Controller();
+ $response = $controller->spawn_agent(
+ new \WP_REST_Request(
+ [
+ 'message' => 'Execution identity for spawn',
+ ]
+ )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 201, $response->get_status() );
+ $this->assertSame( 11, (int) $GLOBALS['wpdb']->sessions[ (int) $data['session_id'] ]['execution_user_id'] );
+ }
+}
diff --git a/tests/Unit/AgentRunHelperTest.php b/tests/Unit/AgentRunHelperTest.php
index 8cb22b4..4db770f 100644
--- a/tests/Unit/AgentRunHelperTest.php
+++ b/tests/Unit/AgentRunHelperTest.php
@@ -31,210 +31,14 @@ function dbDelta( string $queries ): array {
use ClawPress\Helpers\Agent_Run_Helper;
use ClawPress\Helpers\Agent_Session_Helper;
use ClawPress\Plugin;
+use ClawPress\Tests\Support\Agent_Runtime_Wpdb;
use ClawPress\Tests\Support\TestCase;
-/**
- * Minimal in-memory wpdb stub for run/session store tests.
- */
-final class AgentRunHelperTestWpdb {
- public string $prefix = 'wp_';
-
- public int $insert_id = 0;
-
- /** @var array> */
- public array $sessions = [];
-
- /** @var array> */
- public array $runs = [];
-
- /** @var array */
- public array $last_prepare_args = [];
-
- public bool $fail_session_update = false;
-
- private bool $in_transaction = false;
-
- /** @var array{sessions:array>,runs:array>,insert_id:int}|null */
- private ?array $transaction_snapshot = null;
-
- public function get_charset_collate(): string {
- return 'DEFAULT CHARSET=utf8mb4';
- }
-
- /**
- * @param string $table Table name.
- * @param array $data Data payload.
- * @param array $format Format list.
- */
- public function insert( string $table, array $data, array $format ) {
- unset( $format );
-
- $this->insert_id++;
- $data['id'] = $this->insert_id;
-
- if ( false !== strpos( $table, 'agent_sessions' ) ) {
- $this->sessions[ $this->insert_id ] = $data;
- return 1;
- }
-
- if ( false !== strpos( $table, 'agent_runs' ) ) {
- $this->runs[ $this->insert_id ] = $data;
- return 1;
- }
-
- return false;
- }
-
- /**
- * @param string $table Table name.
- * @param array $data Data payload.
- * @param array $where Where payload.
- * @param array $format Formats.
- * @param array $where_format Where formats.
- */
- public function update( string $table, array $data, array $where, ?array $format = null, ?array $where_format = null ) {
- unset( $format, $where_format );
-
- $target = false !== strpos( $table, 'agent_sessions' ) ? 'sessions' : ( false !== strpos( $table, 'agent_runs' ) ? 'runs' : '' );
- if ( '' === $target ) {
- return false;
- }
- if ( $this->fail_session_update && 'sessions' === $target ) {
- return false;
- }
-
- $rows = $this->{$target};
- $updated = 0;
-
- foreach ( $rows as $id => $row ) {
- $matches = true;
- foreach ( $where as $key => $value ) {
- $current = $row[ $key ] ?? null;
- if ( (string) $current !== (string) $value ) {
- $matches = false;
- break;
- }
- }
-
- if ( ! $matches ) {
- continue;
- }
-
- $this->{$target}[ $id ] = array_merge( $row, $data );
- ++$updated;
- }
-
- return $updated;
- }
-
- public function query( string $sql ) {
- $sql = strtoupper( trim( $sql ) );
-
- if ( 'START TRANSACTION' === $sql ) {
- $this->in_transaction = true;
- $this->transaction_snapshot = [
- 'sessions' => $this->sessions,
- 'runs' => $this->runs,
- 'insert_id' => $this->insert_id,
- ];
- return 1;
- }
-
- if ( 'COMMIT' === $sql ) {
- $this->in_transaction = false;
- $this->transaction_snapshot = null;
- return 1;
- }
-
- if ( 'ROLLBACK' === $sql ) {
- if ( $this->in_transaction && is_array( $this->transaction_snapshot ) ) {
- $this->sessions = $this->transaction_snapshot['sessions'];
- $this->runs = $this->transaction_snapshot['runs'];
- $this->insert_id = $this->transaction_snapshot['insert_id'];
- }
-
- $this->in_transaction = false;
- $this->transaction_snapshot = null;
- return 1;
- }
-
- if ( str_starts_with( $sql, 'UPDATE' ) && false !== strpos( $sql, 'AGENT_SESSIONS' ) ) {
- if ( $this->fail_session_update ) {
- return false;
- }
-
- $session_id = isset( $this->last_prepare_args[5] ) ? (int) $this->last_prepare_args[5] : 0;
- if ( $session_id <= 0 || ! isset( $this->sessions[ $session_id ] ) ) {
- return 0;
- }
-
- $run_status = isset( $this->last_prepare_args[1] ) ? (string) $this->last_prepare_args[1] : '';
- $failures = (int) ( $this->sessions[ $session_id ]['consecutive_failures'] ?? 0 );
- if ( 'success' === $run_status ) {
- $failures = 0;
- } else {
- ++$failures;
- }
-
- $this->sessions[ $session_id ]['last_run_at_gmt'] = isset( $this->last_prepare_args[0] ) ? (string) $this->last_prepare_args[0] : null;
- $this->sessions[ $session_id ]['last_run_status'] = $run_status;
- $this->sessions[ $session_id ]['consecutive_failures'] = $failures;
- $this->sessions[ $session_id ]['next_run_at_gmt'] = $this->last_prepare_args[3] ?? null;
- $this->sessions[ $session_id ]['updated_at_gmt'] = isset( $this->last_prepare_args[4] ) ? (string) $this->last_prepare_args[4] : null;
- return 1;
- }
-
- return false;
- }
-
- /**
- * @param string $query Query string.
- * @param array|mixed ...$args Prepare args.
- */
- public function prepare( string $query, ...$args ): string {
- if ( 1 === count( $args ) && is_array( $args[0] ) ) {
- $args = $args[0];
- }
-
- $this->last_prepare_args = $args;
- return $query;
- }
-
- /**
- * @return array|null
- */
- public function get_row( string $query, string $output ) {
- unset( $output );
-
- $id = 0;
- if ( preg_match( '/WHERE id =\s*(\d+)/', $query, $matches ) ) {
- $id = (int) $matches[1];
- }
- if ( $id <= 0 ) {
- $id = isset( $this->last_prepare_args[0] ) ? (int) $this->last_prepare_args[0] : 0;
- }
-
- if ( $id <= 0 ) {
- return null;
- }
-
- if ( false !== strpos( $query, 'agent_sessions' ) ) {
- return $this->sessions[ $id ] ?? null;
- }
-
- if ( false !== strpos( $query, 'agent_runs' ) ) {
- return $this->runs[ $id ] ?? null;
- }
-
- return null;
- }
-}
-
final class AgentRunHelperTest extends TestCase {
protected function setUp(): void {
parent::setUp();
$GLOBALS['clawpress_test_dbdelta_queries'] = [];
- $GLOBALS['wpdb'] = new AgentRunHelperTestWpdb();
+ $GLOBALS['wpdb'] = new Agent_Runtime_Wpdb();
}
protected function tearDown(): void {
@@ -274,6 +78,17 @@ public function test_claim_collision_fails_for_second_worker(): void {
$this->assertSame( 'not_claimable', $second['reason'] );
}
+ public function test_first_claim_keeps_attempt_at_one(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
+
+ $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 );
+
+ $this->assertTrue( $result['claimed'] );
+ $this->assertSame( 1, $result['attempt'] );
+ $this->assertSame( 1, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] );
+ }
+
public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void {
$session_id = Agent_Session_Helper::get_instance()->create_session();
$run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
@@ -289,6 +104,170 @@ public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void
$this->assertSame( 2, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] );
}
+ public function test_claim_paused_continuation_does_not_increment_attempt(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'status' => 'paused',
+ 'attempt' => 1,
+ 'meta' => [
+ 'pause_reason' => 'slice_budget',
+ ],
+ ]
+ );
+
+ $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-continuation', 120 );
+
+ $this->assertTrue( $result['claimed'] );
+ $this->assertSame( 1, $result['attempt'] );
+ $this->assertSame( 1, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] );
+ }
+
+ public function test_claim_paused_retry_backoff_increments_attempt(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'status' => 'paused',
+ 'attempt' => 1,
+ 'meta' => [
+ 'pause_reason' => 'retry_backoff',
+ ],
+ ]
+ );
+
+ $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-retry', 120 );
+
+ $this->assertTrue( $result['claimed'] );
+ $this->assertSame( 2, $result['attempt'] );
+ $this->assertSame( 2, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] );
+ }
+
+ public function test_claim_session_allows_paused_status(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session(
+ [
+ 'status' => 'paused',
+ ]
+ );
+
+ $claim = Agent_Session_Helper::get_instance()->claim_session( $session_id, 'worker-paused', 120 );
+
+ $this->assertTrue( $claim['claimed'] );
+ $this->assertSame( 'running', $GLOBALS['wpdb']->sessions[ $session_id ]['status'] );
+ }
+
+ public function test_pause_run_does_not_increment_session_failures(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
+ $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 );
+
+ $paused = Agent_Run_Helper::get_instance()->pause_run(
+ $run_id,
+ (string) $claim['lock_token'],
+ [
+ 'status' => 'paused',
+ ]
+ );
+
+ $this->assertTrue( $paused );
+ $this->assertSame( 'paused', $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] );
+ $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] );
+ }
+
+ public function test_pause_run_clears_lock_metadata_and_persists_retry_count(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
+ $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 );
+
+ $paused = Agent_Run_Helper::get_instance()->pause_run(
+ $run_id,
+ (string) $claim['lock_token'],
+ [
+ 'status' => 'paused',
+ 'retry_count' => 2,
+ ]
+ );
+
+ $this->assertTrue( $paused );
+ $run = Agent_Run_Helper::get_instance()->get_run( $run_id );
+ $this->assertSame( 'paused', $run['status'] );
+ $this->assertSame( 2, (int) $run['retry_count'] );
+ $this->assertNull( $run['lock_token'] );
+ $this->assertNull( $run['claimed_by'] );
+ $this->assertNull( $run['lock_acquired_at_gmt'] );
+ $this->assertNull( $run['lock_expires_at_gmt'] );
+ }
+
+ public function test_pause_run_preserves_existing_resume_cursor_and_merges_meta(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'meta' => [
+ 'message' => 'hello',
+ 'slice_budget_ms' => 1500,
+ 'max_steps_per_slice' => 2,
+ ],
+ 'resume_cursor' => [
+ 'version' => 1,
+ 'round' => 2,
+ ],
+ ]
+ );
+ $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 );
+
+ $paused = Agent_Run_Helper::get_instance()->pause_run(
+ $run_id,
+ (string) $claim['lock_token'],
+ [
+ 'status' => 'paused',
+ 'retry_count' => 1,
+ 'meta' => [
+ 'pause_reason' => 'retry_backoff',
+ ],
+ ]
+ );
+
+ $this->assertTrue( $paused );
+ $run = Agent_Run_Helper::get_instance()->get_run( $run_id );
+ $this->assertSame( 1, (int) $run['retry_count'] );
+ $this->assertSame( 'hello', $run['meta']['message'] ?? '' );
+ $this->assertSame( 1500, (int) ( $run['meta']['slice_budget_ms'] ?? 0 ) );
+ $this->assertSame( 2, (int) ( $run['meta']['max_steps_per_slice'] ?? 0 ) );
+ $this->assertSame( 'retry_backoff', $run['meta']['pause_reason'] ?? '' );
+ $this->assertSame( 1, (int) ( $run['resume_cursor']['version'] ?? 0 ) );
+ $this->assertSame( 2, (int) ( $run['resume_cursor']['round'] ?? 0 ) );
+ }
+
+ public function test_enqueue_run_rejects_non_terminal_statuses(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
+ $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 );
+
+ $this->assertTrue( $claim['claimed'] );
+ $this->assertFalse( Agent_Run_Helper::get_instance()->enqueue_run( $run_id ) );
+ }
+
+ public function test_requires_confirmation_does_not_increment_session_failures(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $this->assertTrue( Agent_Session_Helper::get_instance()->apply_run_completion( $session_id, 'failed', null ) );
+ $this->assertSame( 1, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] );
+
+ $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
+ $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 );
+ $this->assertTrue( $claim['claimed'] );
+
+ $completed = Agent_Run_Helper::get_instance()->complete_run(
+ $run_id,
+ (string) $claim['lock_token'],
+ 'requires_confirmation'
+ );
+
+ $this->assertTrue( $completed );
+ $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] );
+ }
+
public function test_complete_run_clears_lock_and_updates_session_state(): void {
$session_id = Agent_Session_Helper::get_instance()->create_session();
$run_id = Agent_Run_Helper::get_instance()->create_run( $session_id );
@@ -357,5 +336,34 @@ public function test_helpers_do_not_expose_schema_methods(): void {
$this->assertFalse( method_exists( Agent_Session_Helper::class, 'create_table' ) );
$this->assertFalse( method_exists( Agent_Session_Helper::class, 'get_table_name' ) );
}
+
+ public function test_create_run_recovers_from_duplicate_insert_race_with_idempotency_key(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session();
+ $wpdb = $GLOBALS['wpdb'];
+ $this->assertInstanceOf( Agent_Runtime_Wpdb::class, $wpdb );
+ $wpdb->simulate_idempotency_race = true;
+
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'idempotency_key' => 'race-key-123',
+ 'meta' => [
+ 'message' => 'race test',
+ ],
+ ]
+ );
+
+ $this->assertGreaterThan( 0, $run_id );
+ $this->assertCount( 1, $wpdb->runs );
+ $this->assertSame( 'race-key-123', (string) $wpdb->runs[ $run_id ]['idempotency_key'] );
+
+ $run_id_2 = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'idempotency_key' => 'race-key-123',
+ ]
+ );
+ $this->assertSame( $run_id, $run_id_2 );
+ }
}
}
diff --git a/tests/Unit/AgentRunnerTest.php b/tests/Unit/AgentRunnerTest.php
new file mode 100644
index 0000000..59c1d55
--- /dev/null
+++ b/tests/Unit/AgentRunnerTest.php
@@ -0,0 +1,200 @@
+create_session(
+ [
+ 'status' => 'paused',
+ 'requesting_user_id' => 1,
+ 'execution_user_id' => 1,
+ ]
+ );
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'status' => 'queued',
+ 'meta' => [
+ 'message' => 'Execute queued run.',
+ ],
+ ]
+ );
+
+ $runner = new Agent_Runner();
+ $runner->run_scheduled_tasks();
+
+ $run_row = Agent_Run_Helper::get_instance()->get_run( $run_id );
+ $this->assertSame( 'done', $run_row['status'] );
+ $this->assertNull( $run_row['lock_token'] );
+ $this->assertSame( 'idle', $GLOBALS['wpdb']->sessions[ $session_id ]['status'] );
+ $this->assertSame( 'done', $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] );
+ $history = get_option( 'clawpress_chat_history_1', [] );
+ $this->assertIsArray( $history );
+ $this->assertNotEmpty( $history );
+ $last_history_item = $history[ count( $history ) - 1 ];
+ $this->assertIsArray( $last_history_item );
+ $this->assertSame( 'assistant', $last_history_item['role'] ?? '' );
+ $this->assertNotSame( '', trim( (string) ( $last_history_item['content'] ?? '' ) ) );
+ }
+
+ public function test_enqueue_run_slice_uses_async_and_delayed_single_actions(): void {
+ $runner = new Agent_Runner();
+
+ $runner->enqueue_run_slice( 22, 0 );
+ $runner->enqueue_run_slice( 23, 15 );
+
+ $this->assertCount( 1, WordPress_Stubs::$async_actions );
+ $this->assertSame( Agent_Runner::RUN_SLICE_ACTION_HOOK, WordPress_Stubs::$async_actions[0]['hook'] );
+ $this->assertSame( 22, WordPress_Stubs::$async_actions[0]['args']['run_id'] );
+ $this->assertCount( 1, WordPress_Stubs::$single_scheduled_actions );
+ $this->assertSame( Agent_Runner::RUN_SLICE_ACTION_HOOK, WordPress_Stubs::$single_scheduled_actions[0]['hook'] );
+ $this->assertSame( 23, WordPress_Stubs::$single_scheduled_actions[0]['args']['run_id'] );
+ }
+
+ public function test_runner_marks_run_error_when_session_is_missing(): void {
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ 9999,
+ [
+ 'status' => 'queued',
+ 'meta' => [
+ 'message' => 'Missing session',
+ ],
+ ]
+ );
+
+ $runner = new Agent_Runner();
+ $runner->run_slice_action( $run_id );
+
+ $run = Agent_Run_Helper::get_instance()->get_run( $run_id );
+ $this->assertSame( 'error', $run['status'] );
+ $this->assertSame( 'session_missing', $run['error_code'] );
+ }
+
+ public function test_runner_pauses_run_when_session_cannot_be_claimed(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session(
+ [
+ 'status' => 'running',
+ ]
+ );
+ $GLOBALS['wpdb']->sessions[ $session_id ]['lease_expires_at_gmt'] = gmdate( 'Y-m-d H:i:s', time() + 300 );
+
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'status' => 'queued',
+ 'meta' => [
+ 'message' => 'Busy session',
+ ],
+ ]
+ );
+
+ $runner = new Agent_Runner();
+ $runner->run_slice_action( $run_id );
+
+ $run = Agent_Run_Helper::get_instance()->get_run( $run_id );
+ $this->assertSame( 'paused', $run['status'] );
+ $this->assertNotNull( $run['next_retry_at_gmt'] ?? null );
+ $this->assertSame( 'session_not_claimable', $run['meta']['reason'] ?? null );
+ $this->assertSame( 'session_not_claimable', $run['meta']['pause_reason'] ?? null );
+ $this->assertCount( 1, WordPress_Stubs::$single_scheduled_actions );
+ $this->assertSame( $run_id, WordPress_Stubs::$single_scheduled_actions[0]['args']['run_id'] );
+ }
+
+ public function test_runner_reclaims_stale_running_run_from_heartbeat_scan(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session(
+ [
+ 'status' => 'paused',
+ 'requesting_user_id' => 1,
+ 'execution_user_id' => 1,
+ ]
+ );
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'status' => 'queued',
+ 'meta' => [
+ 'message' => 'Reclaim stale run',
+ ],
+ ]
+ );
+
+ $GLOBALS['wpdb']->runs[ $run_id ]['status'] = 'running';
+ $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] = 1;
+ $GLOBALS['wpdb']->runs[ $run_id ]['lock_token'] = 'stale-lock-token';
+ $GLOBALS['wpdb']->runs[ $run_id ]['lock_expires_at_gmt'] = gmdate( 'Y-m-d H:i:s', time() - 300 );
+
+ $runner = new Agent_Runner();
+ $runner->run_scheduled_tasks();
+
+ $run_row = Agent_Run_Helper::get_instance()->get_run( $run_id );
+ $this->assertSame( 'done', $run_row['status'] );
+ $this->assertSame( 2, (int) $run_row['attempt'] );
+ $this->assertNull( $run_row['lock_token'] );
+ }
+
+ public function test_retry_backoff_calculates_from_retry_count(): void {
+ $runner = new Agent_Runner();
+ $reflection = new \ReflectionClass( Agent_Runner::class );
+ $method = $reflection->getMethod( 'calculate_retry_backoff' );
+ $method->setAccessible( true );
+
+ $this->assertSame( 15, $method->invoke( $runner, 1 ) );
+ $this->assertSame( 30, $method->invoke( $runner, 2 ) );
+ $this->assertSame( 60, $method->invoke( $runner, 3 ) );
+ }
+
+ public function test_runner_times_out_run_when_max_wall_time_is_exceeded(): void {
+ $session_id = Agent_Session_Helper::get_instance()->create_session(
+ [
+ 'status' => 'paused',
+ 'requesting_user_id' => 1,
+ 'execution_user_id' => 1,
+ ]
+ );
+ $run_id = Agent_Run_Helper::get_instance()->create_run(
+ $session_id,
+ [
+ 'status' => 'queued',
+ 'trigger_type' => 'heartbeat',
+ 'meta' => [
+ 'message' => 'Long running job',
+ ],
+ ]
+ );
+
+ $GLOBALS['wpdb']->runs[ $run_id ]['started_at_gmt'] = gmdate( 'Y-m-d H:i:s', time() - 300 );
+
+ $runner = new Agent_Runner();
+ $runner->run_scheduled_tasks();
+
+ $run = Agent_Run_Helper::get_instance()->get_run( $run_id );
+ $this->assertSame( 'timeout', $run['status'] );
+ $this->assertSame( 'wall_time_exceeded', $run['error_code'] );
+ $this->assertSame( 'timeout', $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] );
+ }
+}
diff --git a/tests/Unit/ChatHelperTest.php b/tests/Unit/ChatHelperTest.php
index 17f8440..c19c052 100644
--- a/tests/Unit/ChatHelperTest.php
+++ b/tests/Unit/ChatHelperTest.php
@@ -11,7 +11,9 @@
use ClawPress\Helpers\Chat_Helper;
use ClawPress\Helpers\Memory_Helper;
+use ClawPress\Tests\Support\Agent_Runtime_Wpdb;
use ClawPress\Tests\Support\TestCase;
+use ClawPress\Tests\Support\WordPress_Stubs;
final class ChatHelperTest extends TestCase {
public function test_generate_ai_reply_injects_context_into_online_generator(): void {
@@ -308,4 +310,51 @@ static function (): array {
$this->assertSame( 'requires_confirmation', $payload['tool_calls'][1]['status'] );
$this->assertSame( true, $payload['tool_calls'][1]['requires_confirmation'] );
}
-}
+
+ public function test_generate_ai_reply_returns_in_progress_with_run_metadata_when_slice_pauses(): void {
+ $GLOBALS['wpdb'] = new Agent_Runtime_Wpdb();
+ try {
+ $chat_helper = Chat_Helper::create_for_testing(
+ null,
+ static function (): array {
+ return [
+ 'status' => 'in_progress',
+ 'next_action' => 'continue_later',
+ 'reply' => 'Working on it...',
+ 'resume_cursor' => [
+ 'version' => 1,
+ 'round' => 2,
+ ],
+ 'events_cursor' => 55,
+ 'tool_calls' => [
+ [
+ 'name' => 'file_read',
+ 'status' => 'success',
+ ],
+ ],
+ ];
+ },
+ static fn( array $settings ): array => [
+ 'provider' => 'openai',
+ 'model' => 'gpt-4.1-mini',
+ ]
+ );
+
+ $payload = $chat_helper->generate_ai_reply( 'Long running request' );
+
+ $this->assertSame( 'in_progress', $payload['mode'] );
+ $this->assertSame( 'in_progress', $payload['status'] );
+ $this->assertGreaterThan( 0, (int) $payload['events_cursor'] );
+ $this->assertGreaterThan( 0, (int) $payload['run_id'] );
+ $this->assertGreaterThan( 0, (int) $payload['session_id'] );
+ $this->assertCount( 1, $GLOBALS['wpdb']->runs );
+ $this->assertCount( 1, WordPress_Stubs::$async_actions );
+ $this->assertNotEmpty( $GLOBALS['wpdb']->events );
+ $events = array_values( $GLOBALS['wpdb']->events );
+ $this->assertSame( (int) $payload['run_id'], (int) $events[0]['run_id'] );
+ $this->assertSame( (int) $payload['session_id'], (int) $events[0]['session_id'] );
+ } finally {
+ unset( $GLOBALS['wpdb'] );
+ }
+ }
+ }
diff --git a/tests/Unit/ContextHelperTest.php b/tests/Unit/ContextHelperTest.php
index 93add51..176d9d5 100644
--- a/tests/Unit/ContextHelperTest.php
+++ b/tests/Unit/ContextHelperTest.php
@@ -97,6 +97,36 @@ public function test_build_model_context_converts_history_to_model_messages(): v
$this->assertInstanceOf( FunctionDeclaration::class, $context['tool_declarations'][0] );
}
+ public function test_build_model_context_trims_leading_non_user_messages(): void {
+ update_option( 'clawpress_chat_history_1', [
+ [
+ 'id' => 'msg-1',
+ 'role' => 'assistant',
+ 'content' => 'Interim status',
+ 'createdAt' => 1,
+ ],
+ [
+ 'id' => 'msg-2',
+ 'role' => 'user',
+ 'content' => 'Real request',
+ 'createdAt' => 2,
+ ],
+ [
+ 'id' => 'msg-3',
+ 'role' => 'assistant',
+ 'content' => 'Acknowledged',
+ 'createdAt' => 3,
+ ],
+ ] );
+
+ $helper = Context_Helper::get_instance();
+ $context = $helper->build_model_context( 'Continue' );
+
+ $this->assertCount( 2, $context['history_messages'] );
+ $this->assertSame( 'user', $context['history_messages'][0]->getRole()->value );
+ $this->assertSame( 'model', $context['history_messages'][1]->getRole()->value );
+ }
+
public function test_build_messages_appends_bootstrap_requirement_before_first_assistant_response(): void {
update_option(
'clawpress_settings',
diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php
index a4c0a36..c2a28fa 100644
--- a/tests/Unit/PluginTest.php
+++ b/tests/Unit/PluginTest.php
@@ -29,7 +29,7 @@ public function test_get_instance_wires_all_module_hooks_once(): void {
$hooks = array_column( WordPress_Stubs::$actions, 'hook' );
- $this->assertCount( 16, WordPress_Stubs::$actions );
+ $this->assertCount( 18, WordPress_Stubs::$actions );
$this->assertContains( 'init', $hooks );
$this->assertContains( 'use_block_editor_for_post_type', $hooks );
$this->assertContains( 'rest_api_init', $hooks );
@@ -42,6 +42,8 @@ public function test_get_instance_wires_all_module_hooks_once(): void {
$this->assertContains( 'action_scheduler_init', $hooks );
$this->assertContains( 'action_scheduler_ensure_recurring_actions', $hooks );
$this->assertContains( 'clawpress_heartbeat_tick', $hooks );
+ $this->assertContains( 'clawpress_run_scheduled_tasks', $hooks );
+ $this->assertContains( 'clawpress_agent_run_slice', $hooks );
}
public function test_plugin_boot_admin_menu_hook_registers_expected_menu_items(): void {
diff --git a/tests/Unit/PolicyHelperTest.php b/tests/Unit/PolicyHelperTest.php
index dc0c1f5..b948eed 100644
--- a/tests/Unit/PolicyHelperTest.php
+++ b/tests/Unit/PolicyHelperTest.php
@@ -20,8 +20,8 @@ public function test_chat_trigger_uses_default_policy_contract(): void {
$this->assertTrue( $policy['allow_tools'] );
$this->assertTrue( $policy['allow_destructive_tools'] );
$this->assertTrue( $policy['require_confirmation_for_destructive'] );
- $this->assertSame( 4, $policy['max_tool_rounds'] );
- $this->assertSame( 6, $policy['max_tool_calls_per_round'] );
+ $this->assertSame( 6, $policy['max_tool_rounds'] );
+ $this->assertSame( 8, $policy['max_tool_calls_per_round'] );
}
public function test_heartbeat_trigger_resolves_stricter_defaults(): void {
diff --git a/tests/Unit/RestApiTest.php b/tests/Unit/RestApiTest.php
index dcaa9f7..67fcc78 100644
--- a/tests/Unit/RestApiTest.php
+++ b/tests/Unit/RestApiTest.php
@@ -37,7 +37,7 @@ static function ( array $route ): string {
WordPress_Stubs::$rest_routes
);
- $this->assertCount( 7, WordPress_Stubs::$rest_routes );
+ $this->assertCount( 12, WordPress_Stubs::$rest_routes );
$this->assertContains( '/settings:GET', $routes );
$this->assertContains( '/settings:POST', $routes );
$this->assertContains( '/status:GET', $routes );
@@ -45,6 +45,11 @@ static function ( array $route ): string {
$this->assertContains( '/panel/state:POST', $routes );
$this->assertContains( '/chat/message:POST', $routes );
$this->assertContains( '/chat/history:GET', $routes );
+ $this->assertContains( '/agent/runs:POST', $routes );
+ $this->assertContains( '/agent/runs/(?P\\d+)/enqueue:POST', $routes );
+ $this->assertContains( '/agent/runs/(?P\\d+):GET', $routes );
+ $this->assertContains( '/agent/runs/(?P\\d+)/events:GET', $routes );
+ $this->assertContains( '/agent/spawn:POST', $routes );
}
public function test_settings_routes_use_global_manage_options_permission_callback(): void {
@@ -243,12 +248,49 @@ static function ( string $message ): array {
'error' => null,
'context' => null,
'tool_calls' => null,
- ),
+ 'run_id' => null,
+ 'session_id' => null,
+ 'events_cursor' => null,
+ 'status' => null,
+ ),
),
$response->get_data()
);
}
+ public function test_chat_send_message_exposes_in_progress_run_metadata(): void {
+ $chat_controller = new Chat_Controller(
+ static function ( string $message ): array {
+ return array(
+ 'reply' => 'Still working: ' . $message,
+ 'mode' => 'in_progress',
+ 'provider' => 'openai',
+ 'model' => 'gpt-4.1-mini',
+ 'run_id' => 42,
+ 'session_id' => 84,
+ 'events_cursor' => 123,
+ 'status' => 'in_progress',
+ );
+ }
+ );
+
+ $response = $chat_controller->send_message(
+ new \WP_REST_Request(
+ array(
+ 'message' => 'Continue',
+ )
+ )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'in_progress', $data['meta']['mode'] );
+ $this->assertSame( 42, $data['meta']['run_id'] );
+ $this->assertSame( 84, $data['meta']['session_id'] );
+ $this->assertSame( 123, $data['meta']['events_cursor'] );
+ $this->assertSame( 'in_progress', $data['meta']['status'] );
+ }
+
public function test_chat_command_dispatch_skips_reply_generator(): void {
$reply_generator_calls = 0;
$chat_controller = new Chat_Controller(
@@ -611,4 +653,5 @@ public function test_panel_state_update_and_get_round_trip(): void {
$get_response->get_data()
);
}
+
}
diff --git a/tests/Unit/SecurityTest.php b/tests/Unit/SecurityTest.php
new file mode 100644
index 0000000..d3acf42
--- /dev/null
+++ b/tests/Unit/SecurityTest.php
@@ -0,0 +1,38 @@
+assert_requesting_user_allowed( 1 );
+
+ $this->assertTrue( true === $result );
+ }
+
+ public function test_assert_requesting_user_allowed_rejects_explicit_user_without_capability(): void {
+ WordPress_Stubs::$current_user_id = 2;
+ WordPress_Stubs::$can_manage_options = true;
+ WordPress_Stubs::$user_capabilities[1]['manage_options'] = false;
+
+ $result = Security::get_instance()->assert_requesting_user_allowed( 1 );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'clawpress_requesting_user_forbidden', $result->get_error_code() );
+ }
+}
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index ad6d3a1..f3e8cfd 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -49,4 +49,5 @@
require_once __DIR__ . '/Support/WordPressStubs.php';
require_once __DIR__ . '/Support/TestCase.php';
+require_once __DIR__ . '/Support/AgentRuntimeWpdb.php';
require_once $plugin_root . '/vendor/autoload.php';