fix(adapter): route multiline prompts over stdin on Windows .cmd shims

On Windows, an npm-installed `claude` resolves to a .cmd shim, so
_resolve_launch_command wraps it in `cmd.exe /d /s /c ...`. cmd.exe treats a
newline inside an argument as end-of-command, silently truncating a multiline
prompt at the first line break -- the agent received only the "## Task Brief"
heading and replied that no task was included.

Detect this case in _interactive_prompt_transport and deliver the prompt over
the existing stdin channel instead. The guard requires all three of: nt
platform, a newline in the prompt, and a command that resolves to .cmd/.bat.
It reuses _resolve_windows_command_shim so the check matches the same
resolution logic that decides whether cmd.exe wrapping happens.

Also records prompt_transport_reason in the stdin metadata to distinguish this
trigger from the pre-existing oversized-prompt path.

Verified against the real claude CLI: transport flips to stdin and the CLI
confirms all prompt lines arrive intact.
This commit is contained in:
CatJuly
2026-07-31 09:59:27 +08:00
committed by LZH-YS1998
parent 1268b67223
commit 18ddc18948
2 changed files with 121 additions and 6 deletions
+90 -3
View File
@@ -3137,7 +3137,8 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
task = Task(title="demo", description="body")
prompt = adapter.build_task_prompt(task)
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False):
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
self.assertEqual(cmd[-2:], ["--", prompt])
self.assertEqual(metadata["prompt_transport"], "argv")
@@ -3150,7 +3151,8 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
config=ExternalAgentConfig(command="claude", approval_mode="full-auto")
)
task = Task(title="demo", description="body")
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False):
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})()
tmpdir = _make_test_dir("claude-full-auto-devnull")
@@ -3179,7 +3181,8 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
config=ExternalAgentConfig(command="claude", approval_mode="auto")
)
task = Task(title="demo", description="body")
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False):
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})()
tmpdir = _make_test_dir("claude-auto-approval-stdin")
@@ -3203,6 +3206,90 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(spawn_mock.await_args.kwargs["stdin"], asyncio.subprocess.PIPE)
self.assertEqual(metadata["stdin_policy"], "pipe_open")
def test_claude_adapter_windows_multiline_prompt_uses_stdin_transport(self) -> None:
adapter = ClaudeCodeAdapter()
task = Task(
title="demo",
description="## Task Brief\n在工作区里创建一个 hello.py\n\n## OpenOPC Context\nctx",
metadata={"external_prompt_contract": "description_is_full_prompt"},
)
prompt = adapter.build_task_prompt(task)
with patch("opc.layer3_agent.adapters.claude_code.os.name", "nt"), \
patch(
"opc.layer3_agent.adapters.claude_code.shutil.which",
return_value=r"C:\Users\me\AppData\Roaming\npm\claude.CMD",
):
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
self.assertNotIn("--", cmd)
self.assertNotIn(prompt, cmd)
self.assertIn("--input-format", cmd)
self.assertEqual(cmd[cmd.index("--input-format") + 1], "text")
self.assertEqual(metadata["prompt_transport"], "stdin")
self.assertEqual(metadata["stdin_prompt_channel"], "pipe")
self.assertEqual(metadata["prompt_transport_reason"], "windows_multiline_argv_unsafe")
self.assertEqual(
adapter.stdin_policy_for_process(cmd, metadata),
"pipe_prompt_then_close",
)
def test_claude_adapter_windows_single_line_prompt_stays_on_argv(self) -> None:
adapter = ClaudeCodeAdapter()
task = Task(
title="demo",
description="创建一个 hello.py",
metadata={"external_prompt_contract": "description_is_full_prompt"},
)
prompt = adapter.build_task_prompt(task)
with patch("opc.layer3_agent.adapters.claude_code.os.name", "nt"), \
patch(
"opc.layer3_agent.adapters.claude_code.shutil.which",
return_value=r"C:\Users\me\AppData\Roaming\npm\claude.CMD",
):
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
self.assertEqual(cmd[-2:], ["--", prompt])
self.assertEqual(metadata["prompt_transport"], "argv")
def test_claude_adapter_windows_multiline_batch_prompt_uses_stdin_transport(self) -> None:
adapter = ClaudeCodeAdapter()
task = Task(
title="demo",
description="## Task Brief\n在工作区里创建一个 hello.py",
metadata={"external_prompt_contract": "description_is_full_prompt"},
)
prompt = adapter.build_task_prompt(task)
with patch("opc.layer3_agent.adapters.claude_code.os.name", "nt"), \
patch(
"opc.layer3_agent.adapters.claude_code.shutil.which",
return_value=r"C:\Users\me\AppData\Roaming\npm\claude.CMD",
):
cmd, metadata = adapter.build_invocation(task, workspace_path="/repo")
self.assertNotIn("--", cmd)
self.assertNotIn(prompt, cmd)
self.assertIn("--input-format", cmd)
self.assertEqual(metadata["prompt_transport"], "stdin")
self.assertEqual(metadata["prompt_transport_reason"], "windows_multiline_argv_unsafe")
def test_claude_adapter_posix_multiline_prompt_stays_on_argv(self) -> None:
adapter = ClaudeCodeAdapter()
task = Task(
title="demo",
description="## Task Brief\nline two",
metadata={"external_prompt_contract": "description_is_full_prompt"},
)
prompt = adapter.build_task_prompt(task)
with patch("opc.layer3_agent.adapters.claude_code.os.name", "posix"):
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
self.assertEqual(cmd[-2:], ["--", prompt])
self.assertEqual(metadata["prompt_transport"], "argv")
def test_claude_adapter_large_interactive_prompt_uses_stdin(self) -> None:
adapter = ClaudeCodeAdapter()
prompt = "x" * (ClaudeCodeAdapter._INTERACTIVE_ARGV_PROMPT_MAX_BYTES + 1)