263 lines
11 KiB
Python
Executable File
263 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""A deterministic stand-in for `claude-code-acp` (#134).
|
|
|
|
Speaks just enough JSON-RPC over stdio for the daemon's handshake:
|
|
answers `initialize` and `session/new`, logs every method it receives, and
|
|
exits on SIGTERM. Tests point `PATH` at a directory holding this file under
|
|
the adapter's name, so the real adapter (which needs Claude auth and is
|
|
slow) never runs in the deterministic suite.
|
|
|
|
Environment knobs, all optional:
|
|
|
|
FAKE_ACP_LOG append one line per event to this file:
|
|
start pid=<pid> cwd=<cwd>
|
|
env <NAME>=<value> (for FAKE_ACP_ECHO_ENV)
|
|
caps <json of clientCapabilities>
|
|
recv <method>
|
|
signal SIGTERM
|
|
FAKE_ACP_ECHO_ENV name of one env var to log at start
|
|
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
|
|
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
|
|
`session/new`
|
|
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
|
|
`session/new`
|
|
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
|
|
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
|
|
so the SIGTERM path is exercised)
|
|
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
|
|
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
|
|
FAKE_ACP_CANCEL_SETTLES=1 complete the outstanding prompt on cancel
|
|
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
|
|
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
|
|
notifications ("replayed <i>") before answering,
|
|
the way a real agent streams history back
|
|
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
|
|
FAKE_ACP_DELAY_PROMPT_MS=n wait before answering `session/prompt`
|
|
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
|
|
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
|
|
JSON-RPC error
|
|
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
|
|
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
|
|
callback until a later prompt
|
|
|
|
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
|
|
counting from 1) and logs `emit tick <n>`, so a test can make the agent
|
|
speak on demand (#135).
|
|
"""
|
|
import json
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
LOG = os.environ.get("FAKE_ACP_LOG")
|
|
|
|
|
|
def log(line):
|
|
if not LOG:
|
|
return
|
|
with open(LOG, "a") as f:
|
|
f.write(line + "\n")
|
|
f.flush()
|
|
|
|
|
|
def send(obj):
|
|
sys.stdout.write(json.dumps(obj) + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def on_sigterm(_signum, _frame):
|
|
log("signal SIGTERM")
|
|
sys.exit(0)
|
|
|
|
|
|
signal.signal(signal.SIGTERM, on_sigterm)
|
|
|
|
session_id = f"fake-{os.getpid()}"
|
|
ticks = 0
|
|
prompt_failures = 0
|
|
pending_prompt_id = None
|
|
pending_permission_id = None
|
|
delayed_permission = False
|
|
current_model = "opus"
|
|
current_mode = "default"
|
|
current_effort = "high"
|
|
|
|
|
|
def settings_response(include_session=False):
|
|
result = {}
|
|
if include_session:
|
|
result["sessionId"] = session_id
|
|
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
|
|
result.update({
|
|
"models": {
|
|
"currentModelId": current_model,
|
|
"availableModels": [
|
|
{"modelId": "opus", "name": "Opus"},
|
|
{"modelId": "sonnet", "name": "Sonnet"},
|
|
],
|
|
},
|
|
"modes": {
|
|
"currentModeId": current_mode,
|
|
"availableModes": [
|
|
{"id": "default", "name": "Default"},
|
|
{"id": "plan", "name": "Plan"},
|
|
],
|
|
},
|
|
"configOptions": [{
|
|
"id": "effort", "name": "Effort", "category": "thought_level",
|
|
"type": "select", "currentValue": current_effort,
|
|
"options": [
|
|
{"value": "low", "name": "Low"},
|
|
{"value": "high", "name": "High"},
|
|
],
|
|
}],
|
|
})
|
|
return result
|
|
|
|
|
|
def chunk(text):
|
|
return {"jsonrpc": "2.0", "method": "session/update", "params": {
|
|
"sessionId": session_id,
|
|
"update": {"sessionUpdate": "agent_message_chunk",
|
|
"content": {"type": "text", "text": text}},
|
|
}}
|
|
|
|
|
|
def on_sigusr1(_signum, _frame):
|
|
global ticks
|
|
ticks += 1
|
|
send(chunk(f"tick {ticks}"))
|
|
log(f"emit tick {ticks}")
|
|
|
|
|
|
signal.signal(signal.SIGUSR1, on_sigusr1)
|
|
|
|
log(f"start pid={os.getpid()} cwd={os.getcwd()}")
|
|
echo = os.environ.get("FAKE_ACP_ECHO_ENV")
|
|
if echo:
|
|
log(f"env {echo}={os.environ.get(echo, '')}")
|
|
|
|
for raw in sys.stdin:
|
|
raw = raw.strip()
|
|
if not raw:
|
|
continue
|
|
msg = json.loads(raw)
|
|
method = msg.get("method")
|
|
if method is None:
|
|
# A response to something we sent; nothing to do.
|
|
if msg.get("id") == pending_permission_id:
|
|
log("permission response " + json.dumps(msg.get("result"), sort_keys=True))
|
|
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
|
"result": {"stopReason": "end_turn"}})
|
|
pending_prompt_id = None
|
|
pending_permission_id = None
|
|
continue
|
|
log(f"recv {method}")
|
|
rid = msg.get("id")
|
|
params = msg.get("params") or {}
|
|
|
|
if method == "initialize":
|
|
log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True))
|
|
if os.environ.get("FAKE_ACP_INIT_FAIL") == "1":
|
|
send({"jsonrpc": "2.0", "id": rid,
|
|
"error": {"code": -32000, "message": "fake init failure"}})
|
|
continue
|
|
send({"jsonrpc": "2.0", "id": rid, "result": {
|
|
"protocolVersion": params.get("protocolVersion", 1),
|
|
"agentCapabilities": {
|
|
"loadSession": os.environ.get("FAKE_ACP_NO_LOAD") != "1"},
|
|
"agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"},
|
|
"authMethods": [],
|
|
}})
|
|
elif method == "session/new":
|
|
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
|
|
while gate and not os.path.exists(gate):
|
|
time.sleep(0.01)
|
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
|
send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
|
|
send(chunk("hello from fake"))
|
|
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
|
sys.exit(0)
|
|
elif method == "session/load":
|
|
session_id = params.get("sessionId")
|
|
log(f"recv session/load sessionId={session_id}")
|
|
if os.environ.get("FAKE_ACP_LOAD_FAIL") == "1":
|
|
send({"jsonrpc": "2.0", "id": rid,
|
|
"error": {"code": -32000, "message": "fake load failure"}})
|
|
continue
|
|
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
|
|
send(chunk(f"replayed {i + 1}"))
|
|
send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
|
|
send(chunk("hello after load"))
|
|
elif method == "session/set_model":
|
|
current_model = params.get("modelId")
|
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
|
elif method == "session/set_mode":
|
|
current_mode = params.get("modeId")
|
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
|
elif method == "session/set_config_option":
|
|
current_effort = params.get("value")
|
|
send({"jsonrpc": "2.0", "id": rid, "result": {
|
|
"configOptions": settings_response()["configOptions"]}})
|
|
elif method == "session/cancel":
|
|
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
|
send(chunk("final on cancel"))
|
|
if (os.environ.get("FAKE_ACP_CANCEL_SETTLES") == "1"
|
|
and pending_prompt_id is not None):
|
|
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
|
"result": {"stopReason": "cancelled"}})
|
|
pending_prompt_id = None
|
|
pending_permission_id = None
|
|
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
|
|
sys.exit(0)
|
|
elif method == "session/prompt":
|
|
if os.environ.get("FAKE_ACP_PERMISSION_AFTER_CANCEL") == "1":
|
|
if pending_prompt_id is None:
|
|
pending_prompt_id = rid
|
|
delayed_permission = True
|
|
continue
|
|
if delayed_permission:
|
|
delayed_permission = False
|
|
pending_permission_id = "permission-1"
|
|
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
|
"method": "session/request_permission", "params": {
|
|
"sessionId": session_id,
|
|
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
|
"options": [
|
|
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
|
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
|
]
|
|
}})
|
|
continue
|
|
if os.environ.get("FAKE_ACP_REQUEST_PERMISSION") == "1":
|
|
pending_prompt_id = rid
|
|
pending_permission_id = "permission-1"
|
|
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
|
"method": "session/request_permission", "params": {
|
|
"sessionId": session_id,
|
|
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
|
"options": [
|
|
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
|
{"optionId": "session", "name": "Allow session", "kind": "allow_always"},
|
|
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
|
]
|
|
}})
|
|
continue
|
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
|
|
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
|
|
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
|
|
prompt_failures += 1
|
|
send({"jsonrpc": "2.0", "id": rid,
|
|
"error": {"code": -32000, "message": "fake prompt failure"}})
|
|
else:
|
|
send({"jsonrpc": "2.0", "id": rid,
|
|
"result": {"stopReason": "end_turn"}})
|
|
elif rid is not None:
|
|
send({"jsonrpc": "2.0", "id": rid,
|
|
"error": {"code": -32601, "message": f"method not found: {method}"}})
|
|
|
|
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
|
|
while True:
|
|
signal.pause()
|