diff --git a/docs/reference/env-vars.md b/docs/reference/env-vars.md index 15f6a18551..f175a93aaf 100644 --- a/docs/reference/env-vars.md +++ b/docs/reference/env-vars.md @@ -16,6 +16,11 @@ The following environment variables control the configuration of the Nextflow ru ## Nextflow settings +`NXF_AGENT_MODE` +: :::{versionadded} 26.04.0 + ::: +: When `true`, enables agent output mode. In this mode, Nextflow replaces the interactive ANSI log with minimal, structured output optimized for AI agents and non-interactive environments. The output uses tagged lines such as `[PIPELINE]`, `[PROCESS]`, `[WARN]`, `[ERROR]`, and `[SUCCESS]`/`[FAILED]` written to standard error (default: `false`). + `NXF_ANSI_LOG` : Enables/disables ANSI console output (default `true` when ANSI terminal is detected). diff --git a/modules/nextflow/src/main/groovy/nextflow/Session.groovy b/modules/nextflow/src/main/groovy/nextflow/Session.groovy index 1f354e715b..63ed16bbd7 100644 --- a/modules/nextflow/src/main/groovy/nextflow/Session.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/Session.groovy @@ -69,7 +69,7 @@ import nextflow.script.ScriptRunner import nextflow.script.WorkflowMetadata import nextflow.script.dsl.ProcessConfigBuilder import nextflow.spack.SpackConfig -import nextflow.trace.AnsiLogObserver +import nextflow.trace.LogObserver import nextflow.trace.TraceObserver import nextflow.trace.TraceObserverFactory import nextflow.trace.TraceObserverFactoryV2 @@ -311,9 +311,11 @@ class Session implements ISession { boolean ansiLog + boolean agentLog + boolean disableJobsCancellation - AnsiLogObserver ansiLogObserver + LogObserver logObserver FilePorter getFilePorter() { filePorter } @@ -832,7 +834,7 @@ class Session implements ISession { shutdown0() notifyError(null) // force termination - ansiLogObserver?.forceTermination() + logObserver?.forceTermination() executorFactory?.signalExecutors() processesBarrier.forceTermination() monitorsBarrier.forceTermination() @@ -1278,8 +1280,8 @@ class Session implements ISession { } void printConsole(String str, boolean newLine=false) { - if( ansiLogObserver ) - ansiLogObserver.appendInfo(str) + if( logObserver ) + logObserver.appendInfo(str) else if( newLine ) System.out.println(str) else @@ -1287,7 +1289,7 @@ class Session implements ISession { } void printConsole(Path file) { - ansiLogObserver ? ansiLogObserver.appendInfo(file.text) : Files.copy(file, System.out) + logObserver ? logObserver.appendInfo(file.text) : Files.copy(file, System.out) } private volatile ThreadPoolManager finalizePoolManager diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CliOptions.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CliOptions.groovy index 4437d43d5f..66988dc367 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CliOptions.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CliOptions.groovy @@ -118,6 +118,12 @@ class CliOptions { if( noColor ) { return ansiLog = false } + + // Disable ANSI log in agent mode for plain, parseable output + if( SysEnv.isAgentMode() ) { + return ansiLog = false + } + return Ansi.isEnabled() } diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy index 6811b640dc..08cb119d23 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy @@ -404,7 +404,8 @@ class CmdRun extends CmdBase implements HubOptions { runner.setPreview(this.preview, previewAction) runner.session.profile = profile runner.session.commandLine = launcher.cliString - runner.session.ansiLog = launcher.options.ansiLog + runner.session.ansiLog = launcher.options.ansiLog && !SysEnv.isAgentMode() + runner.session.agentLog = SysEnv.isAgentMode() runner.session.debug = launcher.options.remoteDebug runner.session.disableJobsCancellation = getDisableJobsCancellation() @@ -435,6 +436,11 @@ class CmdRun extends CmdBase implements HubOptions { } protected void printBanner() { + // Suppress banner in agent mode for minimal output + if( SysEnv.isAgentMode() ) { + return + } + if( launcher.options.ansiLog ){ // Plain header for verbose log log.debug "N E X T F L O W ~ version ${BuildInfo.version}" @@ -499,6 +505,11 @@ class CmdRun extends CmdBase implements HubOptions { } protected void printLaunchInfo(String repo, String head, String revision) { + // Agent mode output is handled by AgentLogObserver + if( SysEnv.isAgentMode() ) { + return + } + if( launcher.options.ansiLog ){ log.debug "${head} [$runName] - revision: ${revision}" diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/AgentLogObserver.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/AgentLogObserver.groovy new file mode 100644 index 0000000000..71d5479c07 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/trace/AgentLogObserver.groovy @@ -0,0 +1,236 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.trace + +import java.util.concurrent.ConcurrentHashMap + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.Session +import nextflow.processor.TaskRun +import nextflow.trace.event.TaskEvent +import nextflow.util.LoggerHelper + +/** + * AI agent-friendly log observer that outputs minimal, structured information + * to standard error, optimized for AI context windows. + * + * Activated via environment variable {@code NXF_AGENT_MODE=1}. + * + * Output format: + * - {@code [PIPELINE] name version | profile=X} + * - {@code [WORKDIR] /path/to/work} + * - {@code [PROCESS hash] name (tag)} + * - {@code [WARN] warning message} (deduplicated) + * - {@code [ERROR] name} with exit/cmd/stderr/workdir + * - {@code [SUCCESS|FAILED] completed=N failed=N cached=N} + * + * @author Edmund Miller + */ +@Slf4j +@CompileStatic +class AgentLogObserver implements TraceObserverV2, LogObserver { + + private Session session + private WorkflowStatsObserver statsObserver + private final Set seenWarnings = ConcurrentHashMap.newKeySet() + private volatile boolean started = false + private volatile boolean completed = false + + /** + * Set the workflow stats observer for retrieving task statistics + */ + void setStatsObserver(WorkflowStatsObserver observer) { + this.statsObserver = observer + } + + /** + * Print a line to standard output (agent format) + */ + protected void println(String line) { + System.err.println(line) + } + + // -- TraceObserverV2 lifecycle methods -- + + @Override + void onFlowCreate(Session session) { + this.session = session + } + + @Override + void onFlowBegin() { + if( started ) + return + started = true + + // Print pipeline info + def manifest = session.manifest + def pipelineName = manifest?.name ?: session.scriptName ?: 'unknown' + def version = manifest?.version ?: '' + def profile = session.profile ?: 'standard' + + def info = "[PIPELINE] ${pipelineName}" + if( version ) + info += " ${version}" + info += " | profile=${profile}" + println(info) + + // Print work directory + def workDir = session.workDir?.toUriString() ?: session.workDir?.toString() + if( workDir ) + println("[WORKDIR] ${workDir}") + } + + @Override + void onFlowComplete() { + if( completed ) + return + completed = true + printSummary() + } + + @Override + void onFlowError(TaskEvent event) { + // Error is already reported by onTaskComplete for failed tasks + } + + @Override + void onTaskSubmit(TaskEvent event) { + def task = event.handler?.task + if( task ) + println("[PROCESS ${task.hashLog}] ${task.name}") + } + + @Override + void onTaskComplete(TaskEvent event) { + def handler = event.handler + def task = handler?.task + if( task?.isFailed() ) { + printTaskError(task) + } + } + + @Override + void onTaskCached(TaskEvent event) { + // Not reported in agent mode + } + + /** + * Append a warning message (deduplicated) + */ + void appendWarning(String message) { + if( message == null ) + return + // Normalize and deduplicate + def normalized = message.trim().replaceAll(/\s+/, ' ') + if( seenWarnings.add(normalized) ) { + println("[WARN] ${normalized}") + } + } + + /** + * Append an error message + */ + void appendError(String message) { + if( message ) + println("[ERROR] ${message}") + } + + /** + * Append info message to stdout. + * Hash-prefixed task log lines (e.g. {@code [ab/123456] Submitted process > ...}) + * are filtered out because {@link #onTaskSubmit} already emits a {@code [PROCESS]} line. + */ + void appendInfo(String message) { + if( message && !LoggerHelper.isHashLogPrefix(message) ) + System.out.print(message) + } + + /** + * Print task error with full diagnostic context + */ + protected void printTaskError(TaskRun task) { + def name = task.getName() + println("[ERROR] ${name}") + + // Exit status + def exitStatus = task.getExitStatus() + if( exitStatus != null && exitStatus != Integer.MAX_VALUE ) { + println("exit: ${exitStatus}") + } + + // Command/script (first line or truncated) + def script = task.getScript()?.toString()?.trim() + if( script ) { + // Truncate long commands + def cmd = script.length() > 200 ? script.substring(0, 200) + '...' : script + cmd = cmd.replaceAll(/\n/, ' ').replaceAll(/\s+/, ' ') + println("cmd: ${cmd}") + } + + // Stderr + def stderr = task.getStderr() + if( stderr ) { + def lines = stderr.readLines() + if( lines.size() > 10 ) { + lines = lines[-10..-1] + } + println("stderr: ${lines.join(' | ')}") + } + + // Stdout (only if relevant) + def stdout = task.getStdout() + if( stdout && !stderr ) { + def lines = stdout.readLines() + if( lines.size() > 5 ) { + lines = lines[-5..-1] + } + println("stdout: ${lines.join(' | ')}") + } + + // Work directory + def workDir = task.getWorkDir() + if( workDir ) { + println("workdir: ${workDir.toUriString()}") + } + } + + /** + * Print final summary line + */ + protected void printSummary() { + def stats = statsObserver?.getStats() + def succeeded = stats?.succeededCount ?: 0 + def failed = stats?.failedCount ?: 0 + def cached = stats?.cachedCount ?: 0 + def completed = succeeded + failed + + def status = failed > 0 ? 'FAILED' : 'SUCCESS' + println("\n[${status}] completed=${completed} failed=${failed} cached=${cached}") + } + + /** + * Force termination - called on abort + */ + void forceTermination() { + if( !completed ) { + completed = true + printSummary() + } + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy index e1fda00f89..d710efc244 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy @@ -39,7 +39,7 @@ import org.fusesource.jansi.AnsiConsole * @author Paolo Di Tommaso */ @CompileStatic -class AnsiLogObserver implements TraceObserverV2 { +class AnsiLogObserver implements TraceObserverV2, LogObserver { static final private String NEWLINE = '\n' diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/DefaultObserverFactory.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/DefaultObserverFactory.groovy index d34f023f78..6fbecb499f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/DefaultObserverFactory.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/DefaultObserverFactory.groovy @@ -39,6 +39,7 @@ class DefaultObserverFactory implements TraceObserverFactoryV2 { final result = new ArrayList(5) createAnsiLogObserver(result) + createAgentLogObserver(result) createGraphObserver(result) createReportObserver(result) createTimelineObserver(result) @@ -48,8 +49,18 @@ class DefaultObserverFactory implements TraceObserverFactoryV2 { protected void createAnsiLogObserver(Collection result) { if( session.ansiLog ) { - session.ansiLogObserver = new AnsiLogObserver() - result << session.ansiLogObserver + def observer = new AnsiLogObserver() + session.logObserver = observer + result << observer + } + } + + protected void createAgentLogObserver(Collection result) { + if( session.agentLog ) { + def observer = new AgentLogObserver() + observer.setStatsObserver(session.statsObserver) + session.logObserver = observer + result << observer } } diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/LogObserver.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/LogObserver.groovy new file mode 100644 index 0000000000..757815d7f3 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/trace/LogObserver.groovy @@ -0,0 +1,43 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.trace + +/** + * Common interface for log observer implementations (ANSI and Agent modes). + * + * Only one log observer is active at a time. This interface defines the + * shared contract used by {@link nextflow.Session} and + * {@link nextflow.util.LoggerHelper}. + * + * @author Paolo Di Tommaso + */ +interface LogObserver { + + void appendInfo(String message) + + void appendWarning(String message) + + void appendError(String message) + + void forceTermination() + + default void appendSticky(String message) { } + + default boolean getStarted() { true } + + default boolean getStopped() { false } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/util/LoggerHelper.groovy b/modules/nextflow/src/main/groovy/nextflow/util/LoggerHelper.groovy index 74945c1f60..318f1a098f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/util/LoggerHelper.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/util/LoggerHelper.groovy @@ -56,6 +56,7 @@ import groovyx.gpars.dataflow.DataflowReadChannel import groovyx.gpars.dataflow.DataflowWriteChannel import nextflow.Global import nextflow.Session +import nextflow.SysEnv import nextflow.cli.CliOptions import nextflow.cli.Launcher import nextflow.exception.AbortOperationException @@ -260,7 +261,7 @@ class LoggerHelper { final Appender result = daemon && opts.isBackground() ? (Appender) null - : (opts.ansiLog ? new CaptureAppender() : new ConsoleAppender()) + : ((opts.ansiLog || SysEnv.isAgentMode()) ? new CaptureAppender() : new ConsoleAppender()) if( result ) { final filter = new ConsoleLoggerFilter( packages ) filter.setContext(loggerContext) @@ -703,7 +704,7 @@ class LoggerHelper { /** * Capture logging events and forward them to. This is only used when - * ANSI interactive logging is enabled + * ANSI interactive logging or agent logging is enabled */ static private class CaptureAppender extends AppenderBase { @@ -713,21 +714,23 @@ class LoggerHelper { try { final message = fmtEvent(event, session, false) - final renderer = session?.ansiLogObserver - if( !renderer || !renderer.started || renderer.stopped ) - System.out.println(message) - - else if( event.marker == STICKY ) - renderer.appendSticky(message) - - else if( event.level==Level.ERROR ) - renderer.appendError(message) - - else if( event.level==Level.WARN ) - renderer.appendWarning(message) - else - renderer.appendInfo(message) + final observer = session?.logObserver + if( observer ) { + if( !observer.started || observer.stopped ) + System.out.println(message) + else if( event.marker == STICKY ) + observer.appendSticky(message) + else if( event.level==Level.ERROR ) + observer.appendError(message) + else if( event.level==Level.WARN ) + observer.appendWarning(message) + else + observer.appendInfo(message) + } + else { + System.out.println(message) + } } catch (Throwable e) { e.printStackTrace() diff --git a/modules/nextflow/src/test/groovy/nextflow/trace/AgentLogObserverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/trace/AgentLogObserverTest.groovy new file mode 100644 index 0000000000..b8648789e6 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/trace/AgentLogObserverTest.groovy @@ -0,0 +1,224 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.trace + +import java.nio.file.Paths + +import nextflow.Session +import nextflow.processor.TaskHandler +import nextflow.processor.TaskRun +import nextflow.trace.event.TaskEvent +import spock.lang.Specification + +/** + * Tests for AgentLogObserver + * + * @author Edmund Miller + */ +class AgentLogObserverTest extends Specification { + + def 'should output pipeline info on flow begin'() { + given: + def output = [] + def observer = new TestAgentLogObserver(output) + def session = Stub(Session) { + getManifest() >> [name: 'nf-core/rnaseq', version: '3.14.0'] + getScriptName() >> 'main.nf' + getProfile() >> 'test,docker' + getWorkDir() >> Paths.get('/tmp/work') + } + + when: + observer.onFlowCreate(session) + observer.onFlowBegin() + + then: + output[0] == '[PIPELINE] nf-core/rnaseq 3.14.0 | profile=test,docker' + output[1].startsWith('[WORKDIR]') + output[1].contains('/tmp/work') + } + + def 'should deduplicate warnings'() { + given: + def output = [] + def observer = new TestAgentLogObserver(output) + + when: + observer.appendWarning('Task runtime metrics are not reported when using macOS') + observer.appendWarning('Task runtime metrics are not reported when using macOS') + observer.appendWarning('Different warning message') + + then: + output.size() == 2 + output[0] == '[WARN] Task runtime metrics are not reported when using macOS' + output[1] == '[WARN] Different warning message' + } + + def 'should output error with context'() { + given: + def output = [] + def observer = new TestAgentLogObserver(output) + def task = Stub(TaskRun) { + getName() >> 'FASTQC (sample_1)' + getExitStatus() >> 127 + getScript() >> 'fastqc --version' + getStderr() >> 'bash: fastqc: command not found' + getStdout() >> null + getWorkDir() >> Paths.get('/tmp/work/ab/123456') + } + + when: + observer.printTaskError(task) + + then: + output[0] == '[ERROR] FASTQC (sample_1)' + output[1] == 'exit: 127' + output[2] == 'cmd: fastqc --version' + output[3] == 'stderr: bash: fastqc: command not found' + output[4].startsWith('workdir:') + output[4].contains('/tmp/work/ab/123456') + } + + def 'should output summary on flow complete'() { + given: + def output = [] + def stats = new WorkflowStats() + stats.succeededCount = 10 + stats.failedCount = 0 + stats.cachedCount = 5 + def statsObserver = Stub(WorkflowStatsObserver) { + getStats() >> stats + } + def observer = new TestAgentLogObserver(output) + observer.setStatsObserver(statsObserver) + + when: + observer.onFlowComplete() + + then: + output[0] == '\n[SUCCESS] completed=10 failed=0 cached=5' + } + + def 'should output failed summary when tasks fail'() { + given: + def output = [] + def stats = new WorkflowStats() + stats.succeededCount = 5 + stats.failedCount = 2 + stats.cachedCount = 0 + def statsObserver = Stub(WorkflowStatsObserver) { + getStats() >> stats + } + def observer = new TestAgentLogObserver(output) + observer.setStatsObserver(statsObserver) + + when: + observer.onFlowComplete() + + then: + output[0] == '\n[FAILED] completed=7 failed=2 cached=0' + } + + def 'should handle task complete for failed task'() { + given: + def output = [] + def observer = new TestAgentLogObserver(output) + def task = Stub(TaskRun) { + getName() >> 'TEST_PROC' + getExitStatus() >> 1 + getScript() >> 'exit 1' + getStderr() >> 'error' + getStdout() >> null + getWorkDir() >> Paths.get('/work/xx/yy') + isFailed() >> true + } + def handler = Stub(TaskHandler) { + getTask() >> task + } + def event = Stub(TaskEvent) { + getHandler() >> handler + } + + when: + observer.onTaskComplete(event) + + then: + output.size() > 0 + output[0] == '[ERROR] TEST_PROC' + } + + def 'should not output for successful task'() { + given: + def output = [] + def observer = new TestAgentLogObserver(output) + def task = Stub(TaskRun) { + isFailed() >> false + } + def handler = Stub(TaskHandler) { + getTask() >> task + } + def event = Stub(TaskEvent) { + getHandler() >> handler + } + + when: + observer.onTaskComplete(event) + + then: + output.size() == 0 + } + + def 'should truncate long command'() { + given: + def output = [] + def observer = new TestAgentLogObserver(output) + def longCommand = 'x' * 300 + def task = Stub(TaskRun) { + getName() >> 'PROC' + getExitStatus() >> 1 + getScript() >> longCommand + getStderr() >> null + getStdout() >> null + getWorkDir() >> Paths.get('/work') + } + + when: + observer.printTaskError(task) + + then: + def cmdLine = output.find { it.startsWith('cmd:') } + cmdLine != null + cmdLine.length() < 250 + cmdLine.endsWith('...') + } + + /** + * Test subclass that captures output + */ + static class TestAgentLogObserver extends AgentLogObserver { + private List output + + TestAgentLogObserver(List output) { + this.output = output + } + + @Override + protected void println(String line) { + output << line + } + } +} diff --git a/modules/nf-commons/src/main/nextflow/SysEnv.groovy b/modules/nf-commons/src/main/nextflow/SysEnv.groovy index 89c00d6f98..f69e17910d 100644 --- a/modules/nf-commons/src/main/nextflow/SysEnv.groovy +++ b/modules/nf-commons/src/main/nextflow/SysEnv.groovy @@ -50,7 +50,7 @@ class SysEnv { static boolean getBool(String name, boolean defValue) { final result = get(name,String.valueOf(defValue)) - return Boolean.parseBoolean(result) + return Boolean.parseBoolean(result) || result == '1' } static Integer getInteger(String name, Integer defValue) { @@ -63,6 +63,22 @@ class SysEnv { return result!=null ? Long.valueOf(result) : null } + /** + * Check if agent output mode is enabled via environment variables. + * When enabled, Nextflow replaces interactive ANSI logging with minimal, + * structured output optimized for AI agents. + * + * Supported variables (any truthy value activates the mode): + * {@code NXF_AGENT_MODE}, {@code AGENT}, {@code CLAUDECODE}. + * + * @return {@code true} if agent mode is enabled + */ + static boolean isAgentMode() { + return getBool('NXF_AGENT_MODE', false) || + getBool('AGENT', false) || + getBool('CLAUDECODE', false) + } + static void push(Map env) { history.push(holder.getTarget()) holder.setTarget(env) diff --git a/modules/nf-commons/src/test/nextflow/SysEnvTest.groovy b/modules/nf-commons/src/test/nextflow/SysEnvTest.groovy index 380bf556ec..16bddfca81 100644 --- a/modules/nf-commons/src/test/nextflow/SysEnvTest.groovy +++ b/modules/nf-commons/src/test/nextflow/SysEnvTest.groovy @@ -119,4 +119,30 @@ class SysEnvTest extends Specification { [FOO:'0'] | 1 | 0 [FOO:'100'] | 1 | 100 } + + @Unroll + def 'should detect agent mode' () { + given: + SysEnv.push(STATE) + + expect: + SysEnv.isAgentMode() == EXPECTED + + cleanup: + SysEnv.pop() + + where: + STATE | EXPECTED + [:] | false + [NXF_AGENT_MODE:'true'] | true + [NXF_AGENT_MODE:'false'] | false + [AGENT:'true'] | true + [CLAUDECODE:'true'] | true + // Multiple can be set, any true triggers agent mode + [NXF_AGENT_MODE:'true', AGENT:'false'] | true + // Support '1' as truthy value (common Unix convention) + [NXF_AGENT_MODE:'1'] | true + [AGENT:'1'] | true + [CLAUDECODE:'1'] | true + } } diff --git a/tests/agent-output-error.nf b/tests/agent-output-error.nf new file mode 100644 index 0000000000..054d897d7e --- /dev/null +++ b/tests/agent-output-error.nf @@ -0,0 +1,20 @@ +#!/usr/bin/env nextflow +workflow { + FAIL() +} + + +/* + * Test for agent output mode error handling (NXF_AGENT_MODE=true) + * + * This test verifies the error output format in agent mode. + * Run with: NXF_AGENT_MODE=true nextflow run agent-output-error.nf + */ + +process FAIL { + script: + """ + echo "Error message here" >&2 + exit 127 + """ +} diff --git a/tests/agent-output.nf b/tests/agent-output.nf new file mode 100644 index 0000000000..c05fca2b52 --- /dev/null +++ b/tests/agent-output.nf @@ -0,0 +1,35 @@ +#!/usr/bin/env nextflow +workflow { + HELLO | WORLD | view +} + + +/* + * Test for agent output mode (NXF_AGENT_MODE=true) + * + * This test verifies the minimal agent-friendly output format. + * Run with: NXF_AGENT_MODE=true nextflow run agent-output.nf + */ + +process HELLO { + output: + stdout + + script: + """ + echo "Hello from agent mode" + """ +} + +process WORLD { + input: + val msg + + output: + stdout + + script: + """ + echo "World received: ${msg}" + """ +} diff --git a/tests/checks/agent-output-error.nf/.checks b/tests/checks/agent-output-error.nf/.checks new file mode 100644 index 0000000000..a31458d703 --- /dev/null +++ b/tests/checks/agent-output-error.nf/.checks @@ -0,0 +1,47 @@ +#!/bin/bash +# +# Test agent output mode error handling (NXF_AGENT_MODE=true) +# + +set -e + +# Run workflow with agent mode enabled (expect failure) +NXF_AGENT_MODE=true $NXF_CMD -q run $NXF_SCRIPT > stdout.txt 2> stderr.txt && { + echo "ERROR: Workflow should have failed" + exit 1 +} + +# Verify agent output format contains error info +if ! grep -q '\[PIPELINE\]' stdout.txt; then + echo "ERROR: Missing [PIPELINE] in agent output" + cat stdout.txt + exit 1 +fi + +if ! grep -q '\[ERROR\]' stdout.txt; then + echo "ERROR: Missing [ERROR] in agent output" + cat stdout.txt + exit 1 +fi + +if ! grep -q '\[FAILED\]' stdout.txt; then + echo "ERROR: Missing [FAILED] in agent output" + cat stdout.txt + exit 1 +fi + +# Verify error contains exit code +if ! grep -q 'exit: 127' stdout.txt; then + echo "ERROR: Missing exit code in error output" + cat stdout.txt + exit 1 +fi + +# Verify error contains workdir +if ! grep -q 'workdir:' stdout.txt; then + echo "ERROR: Missing workdir in error output" + cat stdout.txt + exit 1 +fi + +echo "Agent error output mode test passed" diff --git a/tests/checks/agent-output.nf/.checks b/tests/checks/agent-output.nf/.checks new file mode 100644 index 0000000000..7934e35750 --- /dev/null +++ b/tests/checks/agent-output.nf/.checks @@ -0,0 +1,46 @@ +#!/bin/bash +# +# Test agent output mode (NXF_AGENT_MODE=true) +# + +set -e + +# Run workflow with agent mode enabled +NXF_AGENT_MODE=true $NXF_CMD -q run $NXF_SCRIPT > stdout.txt 2> stderr.txt || true + +# Verify agent output format in stdout +# Should contain [PIPELINE], [WORKDIR], and [SUCCESS] or [FAILED] + +if ! grep -q '\[PIPELINE\]' stdout.txt; then + echo "ERROR: Missing [PIPELINE] in agent output" + cat stdout.txt + exit 1 +fi + +if ! grep -q '\[WORKDIR\]' stdout.txt; then + echo "ERROR: Missing [WORKDIR] in agent output" + cat stdout.txt + exit 1 +fi + +if ! grep -q '\[SUCCESS\]' stdout.txt; then + echo "ERROR: Missing [SUCCESS] in agent output" + cat stdout.txt + exit 1 +fi + +# Verify banner is suppressed (should NOT contain "N E X T F L O W") +if grep -q 'N E X T F L O W' stdout.txt; then + echo "ERROR: Banner should be suppressed in agent mode" + cat stdout.txt + exit 1 +fi + +# Verify workflow output is present +if ! grep -q 'World received: Hello from agent mode' stdout.txt; then + echo "ERROR: Workflow stdout incorrect" + cat stdout.txt + exit 1 +fi + +echo "Agent output mode test passed" diff --git a/tests/checks/agent-output.nf/.expected b/tests/checks/agent-output.nf/.expected new file mode 100644 index 0000000000..31a0657240 --- /dev/null +++ b/tests/checks/agent-output.nf/.expected @@ -0,0 +1 @@ +World received: Hello from agent mode