Created src/prompt_constants.py (550+ lines) porting all constants from npm src/constants/:
┌──────────────────┬───────────────────────────┬───────────────────────────────────────────────┐ │ Category │ npm Source │ Items │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Product metadata │ product.ts │ URLs, base URLs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ System prefixes │ system.ts │ 3 prompt prefixes │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Cyber risk │ cyberRiskInstruction.ts │ Safety instruction │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ API limits │ apiLimits.ts │ 10 image/PDF/media limits │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Tool limits │ toolLimits.ts │ 6 result size constants │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Spinner verbs │ spinnerVerbs.ts │ 187 whimsical gerunds │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Completion verbs │ turnCompletionVerbs.ts │ 8 past-tense verbs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Figures/symbols │ figures.ts │ 25 Unicode UI symbols │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ XML tags │ xml.ts │ 30+ tag constants │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Messages │ messages.ts │ NO_CONTENT_MESSAGE │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Date utilities │ common.ts │ 4 functions │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Section caching │ systemPromptSections.ts │ Memoized/volatile sections │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Output styles │ outputStyles.ts │ 3 built-in configs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Prompt helpers │ prompts.ts │ Knowledge cutoff, language, scratchpad, hooks │ └──────────────────┴───────────────────────────┴───────────────────────────────────────────────┘ 91 new tests in tests/test_prompt_constants.py. All 17 SQL todos done.
This commit is contained in:
@@ -0,0 +1,878 @@
|
||||
"""
|
||||
Tests for bash_security module.
|
||||
|
||||
Tests are organized by validator function, matching the npm test structure.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.bash_security import (
|
||||
SecurityBehavior,
|
||||
SecurityResult,
|
||||
ValidationContext,
|
||||
bash_command_is_safe,
|
||||
check_shell_security,
|
||||
extract_quoted_content,
|
||||
get_destructive_command_warning,
|
||||
has_unescaped_char,
|
||||
interpret_command_result,
|
||||
is_command_read_only,
|
||||
split_command,
|
||||
strip_safe_redirections,
|
||||
validate_backslash_escaped_operators,
|
||||
validate_backslash_escaped_whitespace,
|
||||
validate_brace_expansion,
|
||||
validate_carriage_return,
|
||||
validate_comment_quote_desync,
|
||||
validate_control_characters,
|
||||
validate_dangerous_patterns,
|
||||
validate_dangerous_variables,
|
||||
validate_empty,
|
||||
validate_git_commit,
|
||||
validate_ifs_injection,
|
||||
validate_incomplete_commands,
|
||||
validate_jq_command,
|
||||
validate_mid_word_hash,
|
||||
validate_newlines,
|
||||
validate_obfuscated_flags,
|
||||
validate_proc_environ_access,
|
||||
validate_quoted_newline,
|
||||
validate_redirections,
|
||||
validate_shell_metacharacters,
|
||||
validate_unicode_whitespace,
|
||||
validate_zsh_dangerous_commands,
|
||||
)
|
||||
|
||||
|
||||
# ---- Helper to build a context ----
|
||||
|
||||
def _ctx(cmd: str) -> ValidationContext:
|
||||
"""Build a ValidationContext for the given command."""
|
||||
base = cmd.strip().split()[0] if cmd.strip() else ''
|
||||
with_dq, fully_unq, keep_qc = extract_quoted_content(cmd)
|
||||
return ValidationContext(
|
||||
original_command=cmd,
|
||||
base_command=base,
|
||||
unquoted_content=with_dq,
|
||||
fully_unquoted_content=strip_safe_redirections(fully_unq),
|
||||
fully_unquoted_pre_strip=fully_unq,
|
||||
unquoted_keep_quote_chars=keep_qc,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# extract_quoted_content
|
||||
# ===========================================================================
|
||||
|
||||
class TestExtractQuotedContent:
|
||||
def test_no_quotes(self):
|
||||
dq, full, kqc = extract_quoted_content('echo hello')
|
||||
assert dq == 'echo hello'
|
||||
assert full == 'echo hello'
|
||||
|
||||
def test_single_quotes_stripped(self):
|
||||
dq, full, kqc = extract_quoted_content("echo 'hello world'")
|
||||
assert 'hello world' not in full
|
||||
assert 'echo' in full
|
||||
|
||||
def test_double_quotes_in_dq_output(self):
|
||||
dq, full, kqc = extract_quoted_content('echo "hello world"')
|
||||
assert 'hello world' in dq # double-quoted content preserved in dq
|
||||
assert 'hello world' not in full # but stripped in fully_unquoted
|
||||
|
||||
def test_escape_handling(self):
|
||||
dq, full, kqc = extract_quoted_content('echo \\$HOME')
|
||||
assert '$HOME' in full
|
||||
|
||||
def test_keep_quote_chars(self):
|
||||
_, _, kqc = extract_quoted_content("echo 'x'#")
|
||||
assert "'" in kqc # quote chars preserved
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# strip_safe_redirections
|
||||
# ===========================================================================
|
||||
|
||||
class TestStripSafeRedirections:
|
||||
def test_dev_null_output(self):
|
||||
assert '>/dev/null' not in strip_safe_redirections('cmd > /dev/null')
|
||||
|
||||
def test_stderr_redirect(self):
|
||||
assert '2>&1' not in strip_safe_redirections('cmd 2>&1')
|
||||
|
||||
def test_dev_null_input(self):
|
||||
assert '</dev/null' not in strip_safe_redirections('cmd < /dev/null')
|
||||
|
||||
def test_preserves_other_redirections(self):
|
||||
result = strip_safe_redirections('cmd > output.txt')
|
||||
assert '> output.txt' in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# has_unescaped_char
|
||||
# ===========================================================================
|
||||
|
||||
class TestHasUnescapedChar:
|
||||
def test_unescaped_backtick(self):
|
||||
assert has_unescaped_char('echo `date`', '`') is True
|
||||
|
||||
def test_escaped_backtick(self):
|
||||
assert has_unescaped_char('echo \\`safe\\`', '`') is False
|
||||
|
||||
def test_double_backslash_then_backtick(self):
|
||||
# \\\` → \\ (literal backslash) + ` (unescaped)
|
||||
assert has_unescaped_char('test\\\\`date`', '`') is True
|
||||
|
||||
def test_no_match(self):
|
||||
assert has_unescaped_char('echo hello', '`') is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# split_command
|
||||
# ===========================================================================
|
||||
|
||||
class TestSplitCommand:
|
||||
def test_simple(self):
|
||||
assert split_command('echo hello') == ['echo hello']
|
||||
|
||||
def test_semicolon(self):
|
||||
assert split_command('echo a; echo b') == ['echo a', 'echo b']
|
||||
|
||||
def test_and_and(self):
|
||||
assert split_command('cmd1 && cmd2') == ['cmd1', 'cmd2']
|
||||
|
||||
def test_pipe(self):
|
||||
assert split_command('cat file | grep pattern') == ['cat file', 'grep pattern']
|
||||
|
||||
def test_or_or(self):
|
||||
assert split_command('cmd1 || cmd2') == ['cmd1', 'cmd2']
|
||||
|
||||
def test_quotes_preserved(self):
|
||||
result = split_command("echo 'a; b'")
|
||||
assert len(result) == 1 # semicolon inside quotes not split
|
||||
|
||||
def test_complex(self):
|
||||
result = split_command('cd /tmp && echo hi; ls | head')
|
||||
assert len(result) == 4
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_empty
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateEmpty:
|
||||
def test_empty(self):
|
||||
assert validate_empty(_ctx('')).behavior == SecurityBehavior.ALLOW
|
||||
|
||||
def test_whitespace_only(self):
|
||||
assert validate_empty(_ctx(' ')).behavior == SecurityBehavior.ALLOW
|
||||
|
||||
def test_non_empty(self):
|
||||
assert validate_empty(_ctx('ls')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_control_characters
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateControlCharacters:
|
||||
def test_null_byte(self):
|
||||
result = validate_control_characters(_ctx('echo\x00hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_bell(self):
|
||||
result = validate_control_characters(_ctx('echo\x07hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean_command(self):
|
||||
result = validate_control_characters(_ctx('echo hello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_tab_allowed(self):
|
||||
result = validate_control_characters(_ctx('echo\thello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_newline_allowed(self):
|
||||
result = validate_control_characters(_ctx('echo\nhello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_incomplete_commands
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateIncompleteCommands:
|
||||
def test_starts_with_tab(self):
|
||||
result = validate_incomplete_commands(_ctx('\techo hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_starts_with_dash(self):
|
||||
result = validate_incomplete_commands(_ctx('-rf /'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_starts_with_operator(self):
|
||||
result = validate_incomplete_commands(_ctx('&& echo hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
result = validate_incomplete_commands(_ctx('; echo hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_normal_command(self):
|
||||
result = validate_incomplete_commands(_ctx('ls -la'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_git_commit
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateGitCommit:
|
||||
def test_not_git(self):
|
||||
result = validate_git_commit(_ctx('echo hello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_simple_commit(self):
|
||||
result = validate_git_commit(_ctx("git commit -m 'initial commit'"))
|
||||
assert result.behavior == SecurityBehavior.ALLOW
|
||||
|
||||
def test_double_quoted_commit(self):
|
||||
result = validate_git_commit(_ctx('git commit -m "fix bug"'))
|
||||
assert result.behavior == SecurityBehavior.ALLOW
|
||||
|
||||
def test_commit_with_substitution(self):
|
||||
result = validate_git_commit(_ctx('git commit -m "$(date)"'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_commit_with_backtick(self):
|
||||
result = validate_git_commit(_ctx('git commit -m "`date`"'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_commit_with_chained_commands(self):
|
||||
result = validate_git_commit(_ctx("git commit -m 'msg'; rm -rf /"))
|
||||
# Should passthrough (not early-allow) due to ; in remainder
|
||||
assert result.behavior != SecurityBehavior.ALLOW
|
||||
|
||||
def test_commit_with_backslash(self):
|
||||
result = validate_git_commit(_ctx('git commit -m "test\\"msg"'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_jq_command
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateJqCommand:
|
||||
def test_not_jq(self):
|
||||
assert validate_jq_command(_ctx('echo hi')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_jq_system(self):
|
||||
result = validate_jq_command(_ctx('jq "system(\"rm -rf /\")"'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_jq_from_file(self):
|
||||
result = validate_jq_command(_ctx('jq -f evil.jq'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_jq_slurpfile(self):
|
||||
result = validate_jq_command(_ctx('jq --slurpfile x data.json'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_safe_jq(self):
|
||||
result = validate_jq_command(_ctx('jq ".name" data.json'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_obfuscated_flags
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateObfuscatedFlags:
|
||||
def test_ansi_c_quoting(self):
|
||||
result = validate_obfuscated_flags(_ctx("find . $'-exec' evil"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_locale_quoting(self):
|
||||
result = validate_obfuscated_flags(_ctx('find . $"-exec" evil'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_echo_safe(self):
|
||||
result = validate_obfuscated_flags(_ctx("echo $'hello'"))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_empty_quotes_before_dash(self):
|
||||
result = validate_obfuscated_flags(_ctx("find . '' -exec evil"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_quoted_flag(self):
|
||||
result = validate_obfuscated_flags(_ctx('find . "-exec" rm {} ;'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_normal_command(self):
|
||||
result = validate_obfuscated_flags(_ctx('ls -la'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_three_consecutive_quotes(self):
|
||||
result = validate_obfuscated_flags(_ctx("find . '''exec"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_shell_metacharacters
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateShellMetacharacters:
|
||||
def test_semicolon_in_quotes(self):
|
||||
result = validate_shell_metacharacters(_ctx('echo "a;b"'))
|
||||
# unquoted_content (with_double_quotes) has the ; inside
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH or result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_find_name_with_pipe(self):
|
||||
# Single-quoted pipe is stripped entirely from unquoted content → safe
|
||||
ctx = _ctx("find . -name '|evil'")
|
||||
result = validate_shell_metacharacters(ctx)
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_double_quoted_metachar(self):
|
||||
# Check that we catch metacharacters in unquoted positions
|
||||
# The npm version checks the double-quote-retained string for
|
||||
# quoted metacharacters, but we strip quote chars. So we test
|
||||
# the actual dangerous case: unquoted semicolon
|
||||
ctx = _ctx('find . -name evil; rm -rf /')
|
||||
# This won't be caught by this specific validator (it looks for
|
||||
# metacharacters INSIDE quoted args, not command separators)
|
||||
result = validate_shell_metacharacters(ctx)
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_clean_command(self):
|
||||
assert validate_shell_metacharacters(_ctx('ls -la')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_dangerous_variables
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateDangerousVariables:
|
||||
def test_variable_in_pipe(self):
|
||||
result = validate_dangerous_variables(_ctx('$CMD | grep x'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_variable_in_redirect(self):
|
||||
result = validate_dangerous_variables(_ctx('echo x > $FILE'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_safe_variable(self):
|
||||
result = validate_dangerous_variables(_ctx('echo $HOME'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_dangerous_patterns
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateDangerousPatterns:
|
||||
def test_backtick(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo `date`'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_dollar_paren(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo $(date)'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_dollar_brace(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo ${PATH}'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_process_substitution(self):
|
||||
result = validate_dangerous_patterns(_ctx('diff <(cmd1) <(cmd2)'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_safe_echo(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo hello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_escaped_backtick(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo \\`safe\\`'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_redirections
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateRedirections:
|
||||
def test_output_redirect(self):
|
||||
result = validate_redirections(_ctx('echo x > file.txt'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_input_redirect(self):
|
||||
result = validate_redirections(_ctx('cat < /etc/passwd'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_dev_null_stripped(self):
|
||||
# >/dev/null is stripped by strip_safe_redirections
|
||||
result = validate_redirections(_ctx('cmd > /dev/null'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_no_redirect(self):
|
||||
result = validate_redirections(_ctx('echo hello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_newlines
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateNewlines:
|
||||
def test_no_newlines(self):
|
||||
assert validate_newlines(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_newline_with_command(self):
|
||||
result = validate_newlines(_ctx('echo hello\nrm -rf /'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_backslash_continuation(self):
|
||||
result = validate_newlines(_ctx('cmd \\\n--flag'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_carriage_return
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateCarriageReturn:
|
||||
def test_no_cr(self):
|
||||
assert validate_carriage_return(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_cr_in_command(self):
|
||||
result = validate_carriage_return(_ctx('echo hello\rworld'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
assert result.is_misparsing is True
|
||||
|
||||
def test_cr_in_double_quotes_safe(self):
|
||||
result = validate_carriage_return(_ctx('echo "hello\rworld"'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_ifs_injection
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateIFSInjection:
|
||||
def test_ifs_variable(self):
|
||||
result = validate_ifs_injection(_ctx('echo$IFS/etc/passwd'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_ifs_expansion(self):
|
||||
result = validate_ifs_injection(_ctx('echo ${IFS:0:1}'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_ifs_injection(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_proc_environ_access
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateProcEnvironAccess:
|
||||
def test_proc_environ(self):
|
||||
result = validate_proc_environ_access(_ctx('cat /proc/self/environ'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_proc_pid_environ(self):
|
||||
result = validate_proc_environ_access(_ctx('cat /proc/1/environ'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_proc_environ_access(_ctx('cat /etc/hosts')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_backslash_escaped_whitespace
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateBackslashEscapedWhitespace:
|
||||
def test_escaped_space(self):
|
||||
result = validate_backslash_escaped_whitespace(_ctx('echo\\ hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_escaped_tab(self):
|
||||
result = validate_backslash_escaped_whitespace(_ctx('echo\\\thello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_backslash_escaped_whitespace(
|
||||
_ctx('echo hello')
|
||||
).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_backslash_escaped_operators
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateBackslashEscapedOperators:
|
||||
def test_escaped_semicolon(self):
|
||||
result = validate_backslash_escaped_operators(_ctx('cat safe.txt \\; echo /etc/passwd'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_escaped_pipe(self):
|
||||
result = validate_backslash_escaped_operators(_ctx('cmd \\| evil'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_backslash_escaped_operators(
|
||||
_ctx('ls -la')
|
||||
).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_inside_quotes_safe(self):
|
||||
result = validate_backslash_escaped_operators(_ctx("echo '\\;'"))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_brace_expansion
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateBraceExpansion:
|
||||
def test_comma_expansion(self):
|
||||
result = validate_brace_expansion(_ctx('echo {a,b,c}'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_sequence_expansion(self):
|
||||
result = validate_brace_expansion(_ctx('echo {1..5}'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_no_expansion(self):
|
||||
result = validate_brace_expansion(_ctx('echo {hello}'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_escaped_brace(self):
|
||||
result = validate_brace_expansion(_ctx('echo \\{a,b\\}'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_excess_closing_braces(self):
|
||||
result = validate_brace_expansion(_ctx("git diff {@'{'0},--output=/tmp/pwned}"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_unicode_whitespace
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateUnicodeWhitespace:
|
||||
def test_nbsp(self):
|
||||
result = validate_unicode_whitespace(_ctx('echo\u00a0hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_em_space(self):
|
||||
result = validate_unicode_whitespace(_ctx('echo\u2003hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_unicode_whitespace(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_mid_word_hash
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateMidWordHash:
|
||||
def test_mid_word_hash(self):
|
||||
result = validate_mid_word_hash(_ctx('echotest#comment'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_word_start_hash(self):
|
||||
result = validate_mid_word_hash(_ctx('echo # comment'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_dollar_brace_hash_safe(self):
|
||||
# ${#var} is bash string length, should be safe
|
||||
result = validate_mid_word_hash(_ctx('echo ${#var}'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_comment_quote_desync
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateCommentQuoteDesync:
|
||||
def test_quote_in_comment(self):
|
||||
result = validate_comment_quote_desync(_ctx("echo hello # it's a comment"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean_comment(self):
|
||||
result = validate_comment_quote_desync(_ctx('echo hello # clean comment'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_no_comment(self):
|
||||
assert validate_comment_quote_desync(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_quoted_newline
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateQuotedNewline:
|
||||
def test_quoted_newline_with_hash(self):
|
||||
result = validate_quoted_newline(_ctx("echo 'hello\n# dangerous line'"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_no_newline(self):
|
||||
assert validate_quoted_newline(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_newline_without_hash(self):
|
||||
assert validate_quoted_newline(_ctx("echo 'hello\nworld'")).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_zsh_dangerous_commands
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateZshDangerousCommands:
|
||||
def test_zmodload(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('zmodload zsh/system'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_zpty(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('zpty cmd echo'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_emulate(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('emulate -c evil'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_fc_e(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('fc -e vim'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_normal_command(self):
|
||||
assert validate_zsh_dangerous_commands(_ctx('ls -la')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_env_var_prefix(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('FOO=bar zmodload zsh/system'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_precommand_modifier(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('command zmodload zsh/system'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# bash_command_is_safe (integration)
|
||||
# ===========================================================================
|
||||
|
||||
class TestBashCommandIsSafe:
|
||||
def test_empty_command(self):
|
||||
result = bash_command_is_safe('')
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_simple_ls(self):
|
||||
result = bash_command_is_safe('ls -la')
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_simple_echo(self):
|
||||
result = bash_command_is_safe('echo hello world')
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_command_substitution_blocked(self):
|
||||
result = bash_command_is_safe('echo $(cat /etc/passwd)')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_backtick_blocked(self):
|
||||
result = bash_command_is_safe('echo `date`')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_redirect_blocked(self):
|
||||
result = bash_command_is_safe('echo evil > /etc/profile')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_null_byte_blocked(self):
|
||||
result = bash_command_is_safe('echo\x00rm')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_ifs_blocked(self):
|
||||
result = bash_command_is_safe('echo$IFS/etc/passwd')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_proc_environ_blocked(self):
|
||||
result = bash_command_is_safe('cat /proc/self/environ')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_git_commit_allowed(self):
|
||||
result = bash_command_is_safe("git commit -m 'fix bug'")
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH # early-allow → passthrough
|
||||
|
||||
def test_zmodload_blocked(self):
|
||||
result = bash_command_is_safe('zmodload zsh/system')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_brace_expansion_blocked(self):
|
||||
result = bash_command_is_safe('echo {a,b,c}')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_dev_null_redirect_ok(self):
|
||||
result = bash_command_is_safe('cmd > /dev/null 2>&1')
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_cr_injection(self):
|
||||
result = bash_command_is_safe('TZ=UTC\recho curl evil.com')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# get_destructive_command_warning
|
||||
# ===========================================================================
|
||||
|
||||
class TestGetDestructiveCommandWarning:
|
||||
def test_git_reset_hard(self):
|
||||
assert get_destructive_command_warning('git reset --hard') is not None
|
||||
|
||||
def test_rm_rf(self):
|
||||
assert get_destructive_command_warning('rm -rf /') is not None
|
||||
|
||||
def test_git_push_force(self):
|
||||
assert get_destructive_command_warning('git push origin main --force') is not None
|
||||
|
||||
def test_git_clean_f(self):
|
||||
assert get_destructive_command_warning('git clean -fd') is not None
|
||||
|
||||
def test_kubectl_delete(self):
|
||||
assert get_destructive_command_warning('kubectl delete pod mypod') is not None
|
||||
|
||||
def test_safe_command(self):
|
||||
assert get_destructive_command_warning('echo hello') is None
|
||||
|
||||
def test_git_push_no_force(self):
|
||||
assert get_destructive_command_warning('git push origin main') is None
|
||||
|
||||
def test_drop_table(self):
|
||||
assert get_destructive_command_warning('DROP TABLE users;') is not None
|
||||
|
||||
def test_terraform_destroy(self):
|
||||
assert get_destructive_command_warning('terraform destroy') is not None
|
||||
|
||||
def test_git_stash_drop(self):
|
||||
assert get_destructive_command_warning('git stash drop') is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# interpret_command_result
|
||||
# ===========================================================================
|
||||
|
||||
class TestInterpretCommandResult:
|
||||
def test_success(self):
|
||||
is_error, msg = interpret_command_result('echo hello', 0, 'hello', '')
|
||||
assert is_error is False
|
||||
|
||||
def test_failure(self):
|
||||
is_error, msg = interpret_command_result('unknown_cmd', 127, '', 'not found')
|
||||
assert is_error is True
|
||||
|
||||
def test_grep_no_match(self):
|
||||
is_error, msg = interpret_command_result('grep pattern file', 1, '', '')
|
||||
assert is_error is False
|
||||
assert msg == 'No matches found'
|
||||
|
||||
def test_grep_error(self):
|
||||
is_error, msg = interpret_command_result('grep pattern file', 2, '', 'error')
|
||||
assert is_error is True
|
||||
|
||||
def test_diff_files_differ(self):
|
||||
is_error, msg = interpret_command_result('diff a b', 1, 'output', '')
|
||||
assert is_error is False
|
||||
assert msg == 'Files differ'
|
||||
|
||||
def test_find_partial(self):
|
||||
is_error, msg = interpret_command_result('find / -name x', 1, '', '')
|
||||
assert is_error is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# is_command_read_only
|
||||
# ===========================================================================
|
||||
|
||||
class TestIsCommandReadOnly:
|
||||
def test_ls(self):
|
||||
assert is_command_read_only('ls -la') is True
|
||||
|
||||
def test_cat(self):
|
||||
assert is_command_read_only('cat file.txt') is True
|
||||
|
||||
def test_grep(self):
|
||||
assert is_command_read_only('grep -r pattern .') is True
|
||||
|
||||
def test_git_status(self):
|
||||
assert is_command_read_only('git status') is True
|
||||
|
||||
def test_git_log(self):
|
||||
assert is_command_read_only('git log --oneline') is True
|
||||
|
||||
def test_git_push(self):
|
||||
assert is_command_read_only('git push') is False
|
||||
|
||||
def test_rm(self):
|
||||
assert is_command_read_only('rm file.txt') is False
|
||||
|
||||
def test_sed_read_only(self):
|
||||
assert is_command_read_only("sed -n '1,5p' file") is True
|
||||
|
||||
def test_sed_in_place(self):
|
||||
assert is_command_read_only("sed -i 's/old/new/' file") is False
|
||||
|
||||
def test_find_safe(self):
|
||||
assert is_command_read_only('find . -name "*.py"') is True
|
||||
|
||||
def test_find_exec(self):
|
||||
assert is_command_read_only('find . -exec rm {} ;') is False
|
||||
|
||||
def test_find_delete(self):
|
||||
assert is_command_read_only('find . -name "*.tmp" -delete') is False
|
||||
|
||||
def test_echo(self):
|
||||
assert is_command_read_only('echo hello') is True
|
||||
|
||||
def test_python_version(self):
|
||||
assert is_command_read_only('python --version') is True
|
||||
|
||||
def test_unknown_command(self):
|
||||
assert is_command_read_only('some_random_command') is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# check_shell_security (integration)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCheckShellSecurity:
|
||||
def test_shell_disabled(self):
|
||||
allowed, msg = check_shell_security('ls', allow_shell=False)
|
||||
assert allowed is False
|
||||
assert 'disabled' in msg.lower()
|
||||
|
||||
def test_safe_command_allowed(self):
|
||||
allowed, msg = check_shell_security('ls -la')
|
||||
assert allowed is True
|
||||
|
||||
def test_destructive_blocked(self):
|
||||
allowed, msg = check_shell_security('rm -rf /', allow_destructive=False)
|
||||
assert allowed is False
|
||||
assert 'destructive' in msg.lower()
|
||||
|
||||
def test_destructive_allowed_when_enabled(self):
|
||||
allowed, msg = check_shell_security('rm -rf /tmp/test', allow_destructive=True)
|
||||
# rm -rf still triggers destructive check, but allow_destructive=True skips it
|
||||
# However rm -rf may also trigger the security check for force-remove
|
||||
# Let's check: the main security check should pass (no injection)
|
||||
# and destructive should be allowed
|
||||
assert allowed is True
|
||||
|
||||
def test_injection_blocked(self):
|
||||
allowed, msg = check_shell_security('echo `evil`')
|
||||
assert allowed is False
|
||||
assert 'backtick' in msg.lower() or 'security' in msg.lower()
|
||||
|
||||
def test_misparsing_always_blocked(self):
|
||||
allowed, msg = check_shell_security('echo\x00rm')
|
||||
assert allowed is False
|
||||
|
||||
def test_safe_git_commit(self):
|
||||
allowed, msg = check_shell_security("git commit -m 'fix'")
|
||||
assert allowed is True
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Tests for src/compact.py – the conversation compaction service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_session import AgentMessage, AgentSessionState
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
from src.compact import (
|
||||
AUTOCOMPACT_BUFFER_TOKENS,
|
||||
ERROR_INCOMPLETE_RESPONSE,
|
||||
ERROR_NOT_ENOUGH_MESSAGES,
|
||||
CompactionResult,
|
||||
compact_conversation,
|
||||
format_compact_summary,
|
||||
get_compact_prompt,
|
||||
get_compact_user_summary_message,
|
||||
)
|
||||
|
||||
|
||||
class TestGetCompactPrompt(unittest.TestCase):
|
||||
"""Tests for the compact prompt builder."""
|
||||
|
||||
def test_basic_prompt_contains_all_nine_sections(self) -> None:
|
||||
prompt = get_compact_prompt()
|
||||
for section in [
|
||||
'1. Primary Request and Intent',
|
||||
'2. Key Technical Concepts',
|
||||
'3. Files and Code Sections',
|
||||
'4. Errors and fixes',
|
||||
'5. Problem Solving',
|
||||
'6. All user messages',
|
||||
'7. Pending Tasks',
|
||||
'8. Current Work',
|
||||
'9. Optional Next Step',
|
||||
]:
|
||||
self.assertIn(section, prompt, f'Missing section: {section}')
|
||||
|
||||
def test_no_tools_preamble_present(self) -> None:
|
||||
prompt = get_compact_prompt()
|
||||
self.assertIn('CRITICAL: Respond with TEXT ONLY', prompt)
|
||||
self.assertIn('Do NOT call any tools', prompt)
|
||||
|
||||
def test_no_tools_trailer_present(self) -> None:
|
||||
prompt = get_compact_prompt()
|
||||
self.assertIn('REMINDER: Do NOT call any tools', prompt)
|
||||
|
||||
def test_analysis_and_summary_example_tags_present(self) -> None:
|
||||
prompt = get_compact_prompt()
|
||||
self.assertIn('<analysis>', prompt)
|
||||
self.assertIn('</analysis>', prompt)
|
||||
self.assertIn('<summary>', prompt)
|
||||
self.assertIn('</summary>', prompt)
|
||||
|
||||
def test_custom_instructions_appended(self) -> None:
|
||||
prompt = get_compact_prompt('Focus on database changes.')
|
||||
self.assertIn('Additional Instructions:', prompt)
|
||||
self.assertIn('Focus on database changes.', prompt)
|
||||
|
||||
def test_empty_custom_instructions_ignored(self) -> None:
|
||||
prompt_no_custom = get_compact_prompt()
|
||||
prompt_empty = get_compact_prompt('')
|
||||
prompt_whitespace = get_compact_prompt(' ')
|
||||
self.assertEqual(prompt_no_custom, prompt_empty)
|
||||
self.assertEqual(prompt_no_custom, prompt_whitespace)
|
||||
|
||||
def test_none_custom_instructions_ignored(self) -> None:
|
||||
prompt_none = get_compact_prompt(None)
|
||||
prompt_no_arg = get_compact_prompt()
|
||||
self.assertEqual(prompt_none, prompt_no_arg)
|
||||
|
||||
|
||||
class TestFormatCompactSummary(unittest.TestCase):
|
||||
"""Tests for the summary formatting / XML stripping."""
|
||||
|
||||
def test_strips_analysis_block(self) -> None:
|
||||
raw = '<analysis>thinking here</analysis>\n\n<summary>result</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('<analysis>', formatted)
|
||||
self.assertNotIn('thinking here', formatted)
|
||||
self.assertIn('result', formatted)
|
||||
|
||||
def test_unwraps_summary_tags(self) -> None:
|
||||
raw = '<summary>The main points.\n1. First</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('<summary>', formatted)
|
||||
self.assertNotIn('</summary>', formatted)
|
||||
self.assertIn('Summary:', formatted)
|
||||
self.assertIn('The main points.', formatted)
|
||||
|
||||
def test_handles_no_xml_tags(self) -> None:
|
||||
raw = 'Plain text summary without any tags.'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertEqual(formatted, raw)
|
||||
|
||||
def test_collapses_excess_blank_lines(self) -> None:
|
||||
raw = '<analysis>x</analysis>\n\n\n\n<summary>y</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('\n\n\n', formatted)
|
||||
|
||||
def test_multiline_analysis_stripped(self) -> None:
|
||||
raw = (
|
||||
'<analysis>\nLine 1\nLine 2\nLine 3\n</analysis>\n'
|
||||
'<summary>Final summary</summary>'
|
||||
)
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('Line 1', formatted)
|
||||
self.assertIn('Final summary', formatted)
|
||||
|
||||
|
||||
class TestGetCompactUserSummaryMessage(unittest.TestCase):
|
||||
"""Tests for the post-compact user message builder."""
|
||||
|
||||
def test_basic_message_structure(self) -> None:
|
||||
msg = get_compact_user_summary_message('<summary>overview</summary>')
|
||||
self.assertIn('continued from a previous conversation', msg)
|
||||
self.assertIn('overview', msg)
|
||||
|
||||
def test_transcript_path_appended(self) -> None:
|
||||
msg = get_compact_user_summary_message(
|
||||
'<summary>ok</summary>',
|
||||
transcript_path='/tmp/transcript.json',
|
||||
)
|
||||
self.assertIn('/tmp/transcript.json', msg)
|
||||
|
||||
def test_suppress_follow_up(self) -> None:
|
||||
msg = get_compact_user_summary_message(
|
||||
'<summary>ok</summary>',
|
||||
suppress_follow_up=True,
|
||||
)
|
||||
self.assertIn('without asking the user any further questions', msg)
|
||||
|
||||
def test_no_suppress_follow_up_default(self) -> None:
|
||||
msg = get_compact_user_summary_message('<summary>ok</summary>')
|
||||
self.assertNotIn('without asking the user any further questions', msg)
|
||||
|
||||
|
||||
class TestCompactConversation(unittest.TestCase):
|
||||
"""Tests for the core compact_conversation() function."""
|
||||
|
||||
def _make_agent(self, tmp_dir: str) -> LocalCodingAgent:
|
||||
"""Create a minimal agent with a session loaded."""
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
return agent
|
||||
|
||||
def _set_session(
|
||||
self, agent: LocalCodingAgent, messages: list[AgentMessage]
|
||||
) -> None:
|
||||
session = AgentSessionState(
|
||||
system_prompt_parts=('You are a helpful assistant.',),
|
||||
messages=messages,
|
||||
)
|
||||
agent.last_session = session
|
||||
|
||||
def _make_messages(self, count: int) -> list[AgentMessage]:
|
||||
msgs: list[AgentMessage] = []
|
||||
for i in range(count):
|
||||
role = 'user' if i % 2 == 0 else 'assistant'
|
||||
msgs.append(
|
||||
AgentMessage(
|
||||
role=role,
|
||||
content=f'Message {i} content. ' * 10,
|
||||
message_id=f'msg_{i}',
|
||||
)
|
||||
)
|
||||
return msgs
|
||||
|
||||
def test_no_session_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
agent.last_session = None
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
self.assertIn('Not enough', result.error)
|
||||
|
||||
def test_empty_session_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
self._set_session(agent, [])
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
|
||||
def test_too_few_messages_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
# With only 2 messages and preserve_count=4, nothing to compact
|
||||
self._set_session(agent, self._make_messages(2))
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
|
||||
def test_successful_compaction(self) -> None:
|
||||
"""Simulate a successful model call and verify session is compacted."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
msgs = self._make_messages(10)
|
||||
self._set_session(agent, msgs)
|
||||
|
||||
# Mock the client's complete method
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
mock_turn = AssistantTurn(
|
||||
content=(
|
||||
'<analysis>Thinking through the conversation...</analysis>\n'
|
||||
'<summary>\n1. Primary Request and Intent:\n'
|
||||
' User wanted to test compaction.\n'
|
||||
'2. Key Technical Concepts:\n - Testing\n'
|
||||
'3. Files and Code Sections:\n - test.py\n'
|
||||
'4. Errors and fixes:\n - None\n'
|
||||
'5. Problem Solving:\n Basic testing.\n'
|
||||
'6. All user messages:\n - "test compaction"\n'
|
||||
'7. Pending Tasks:\n - None\n'
|
||||
'8. Current Work:\n Testing compaction.\n'
|
||||
'9. Optional Next Step:\n Verify it works.\n'
|
||||
'</summary>'
|
||||
),
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = mock_turn
|
||||
|
||||
result = compact_conversation(agent)
|
||||
|
||||
self.assertIsNone(result.error)
|
||||
self.assertGreater(result.pre_compact_token_count, 0)
|
||||
# Session should have fewer messages than original 10
|
||||
self.assertLess(
|
||||
len(agent.last_session.messages), 10,
|
||||
'Session should have fewer messages after compaction',
|
||||
)
|
||||
# Should contain a compact_boundary message
|
||||
boundary_msgs = [
|
||||
m for m in agent.last_session.messages
|
||||
if m.metadata.get('kind') == 'compact_boundary'
|
||||
]
|
||||
self.assertEqual(len(boundary_msgs), 1)
|
||||
# Should contain a compact_summary message
|
||||
summary_msgs = [
|
||||
m for m in agent.last_session.messages
|
||||
if m.metadata.get('kind') == 'compact_summary'
|
||||
]
|
||||
self.assertEqual(len(summary_msgs), 1)
|
||||
# Summary should not contain <analysis> block
|
||||
self.assertNotIn('<analysis>', result.summary_text)
|
||||
# Summary should contain the actual summary content
|
||||
self.assertIn('User wanted to test compaction', result.summary_text)
|
||||
|
||||
def test_api_error_returns_compaction_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
self._set_session(agent, self._make_messages(10))
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.side_effect = RuntimeError('API down')
|
||||
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
self.assertIn('API down', result.error)
|
||||
|
||||
def test_empty_model_response_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
self._set_session(agent, self._make_messages(10))
|
||||
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = AssistantTurn(
|
||||
content='',
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
|
||||
def test_preserves_tail_messages(self) -> None:
|
||||
"""The most recent messages should survive compaction."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
msgs = self._make_messages(12)
|
||||
self._set_session(agent, msgs)
|
||||
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = AssistantTurn(
|
||||
content='<summary>Summarised.</summary>',
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
|
||||
result = compact_conversation(agent)
|
||||
|
||||
self.assertIsNone(result.error)
|
||||
# The last 4 messages (default preserve_count) should still be present
|
||||
session_contents = [m.content for m in agent.last_session.messages]
|
||||
for original_msg in msgs[-4:]:
|
||||
self.assertIn(
|
||||
original_msg.content,
|
||||
session_contents,
|
||||
f'Tail message "{original_msg.message_id}" should be preserved',
|
||||
)
|
||||
|
||||
def test_custom_instructions_passed_to_prompt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
self._set_session(agent, self._make_messages(10))
|
||||
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = AssistantTurn(
|
||||
content='<summary>Custom summary.</summary>',
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
|
||||
compact_conversation(agent, custom_instructions='Focus on CSS.')
|
||||
|
||||
# Check that the API was called with custom instructions in the prompt
|
||||
call_args = agent.client.complete.call_args
|
||||
messages = call_args[0][0]
|
||||
last_user_msg = messages[-1]['content']
|
||||
self.assertIn('Focus on CSS.', last_user_msg)
|
||||
self.assertIn('Additional Instructions:', last_user_msg)
|
||||
|
||||
|
||||
class TestCompactSlashCommand(unittest.TestCase):
|
||||
"""Test the /compact slash command handler end-to-end."""
|
||||
|
||||
def test_slash_compact_returns_success_message(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
# Set up a session with enough messages
|
||||
session = AgentSessionState(
|
||||
system_prompt_parts=('You are a helper.',),
|
||||
messages=[
|
||||
AgentMessage(role='user', content='Hello', message_id=f'u{i}')
|
||||
if i % 2 == 0
|
||||
else AgentMessage(role='assistant', content='Hi', message_id=f'a{i}')
|
||||
for i in range(10)
|
||||
],
|
||||
)
|
||||
agent.last_session = session
|
||||
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = AssistantTurn(
|
||||
content='<summary>Session summarised.</summary>',
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
|
||||
result = agent.run('/compact')
|
||||
|
||||
self.assertIn('Conversation compacted', result.final_output)
|
||||
self.assertIn('Tokens before', result.final_output)
|
||||
|
||||
def test_slash_compact_no_session_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
result = agent.run('/compact')
|
||||
self.assertIn('failed', result.final_output.lower())
|
||||
|
||||
|
||||
class TestConstants(unittest.TestCase):
|
||||
"""Verify key constants match the npm reference."""
|
||||
|
||||
def test_autocompact_buffer_tokens(self) -> None:
|
||||
self.assertEqual(AUTOCOMPACT_BUFFER_TOKENS, 13_000)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Tests for /cost, /exit, and /diff slash commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_slash_commands import preprocess_slash_command
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig, UsageStats
|
||||
|
||||
|
||||
class TestCostCommand(unittest.TestCase):
|
||||
"""Tests for the /cost slash command."""
|
||||
|
||||
def test_cost_shows_zero_initially(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
result = agent.run('/cost')
|
||||
self.assertIn('Total cost:', result.final_output)
|
||||
self.assertIn('$0.0000', result.final_output)
|
||||
self.assertIn('Total input tokens:', result.final_output)
|
||||
self.assertIn('Total output tokens:', result.final_output)
|
||||
self.assertIn('Total tokens:', result.final_output)
|
||||
|
||||
def test_cost_shows_accumulated_usage(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
# Simulate accumulated usage
|
||||
agent.cumulative_usage = UsageStats(
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
cache_read_input_tokens=200,
|
||||
)
|
||||
agent.cumulative_cost_usd = 0.05
|
||||
result = agent.run('/cost')
|
||||
self.assertIn('$0.05', result.final_output)
|
||||
self.assertIn('1,000', result.final_output)
|
||||
self.assertIn('500', result.final_output)
|
||||
self.assertIn('Cache read tokens:', result.final_output)
|
||||
|
||||
def test_cost_hides_zero_cache_and_reasoning(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
agent.cumulative_usage = UsageStats(
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
)
|
||||
result = agent.run('/cost')
|
||||
# Should NOT show cache/reasoning lines when they're zero
|
||||
self.assertNotIn('Cache read tokens:', result.final_output)
|
||||
self.assertNotIn('Cache creation tokens:', result.final_output)
|
||||
self.assertNotIn('Reasoning tokens:', result.final_output)
|
||||
|
||||
def test_cost_small_amounts_show_four_decimals(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
agent.cumulative_cost_usd = 0.0023
|
||||
result = agent.run('/cost')
|
||||
self.assertIn('$0.0023', result.final_output)
|
||||
|
||||
|
||||
class TestExitCommand(unittest.TestCase):
|
||||
"""Tests for the /exit and /quit slash commands."""
|
||||
|
||||
def test_exit_triggers_system_exit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
agent.run('/exit')
|
||||
self.assertEqual(cm.exception.code, 0)
|
||||
|
||||
def test_quit_triggers_system_exit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
with self.assertRaises(SystemExit):
|
||||
agent.run('/quit')
|
||||
|
||||
|
||||
class TestDiffCommand(unittest.TestCase):
|
||||
"""Tests for the /diff slash command."""
|
||||
|
||||
def test_diff_in_non_git_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
result = agent.run('/diff')
|
||||
# In a non-git dir, git diff returns an error
|
||||
output = result.final_output.lower()
|
||||
self.assertTrue(
|
||||
'no uncommitted' in output
|
||||
or 'not a git' in output
|
||||
or 'error' in output,
|
||||
f'Expected a non-git or no-changes message, got: {result.final_output}',
|
||||
)
|
||||
|
||||
def test_diff_in_git_repo_with_no_changes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
subprocess.run(
|
||||
['git', 'init'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'config', 'user.email', 'test@test.com'],
|
||||
cwd=str(workspace), capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'config', 'user.name', 'Test'],
|
||||
cwd=str(workspace), capture_output=True,
|
||||
)
|
||||
(workspace / 'hello.txt').write_text('hello\n')
|
||||
subprocess.run(
|
||||
['git', 'add', '.'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'commit', '-m', 'init'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent.run('/diff')
|
||||
self.assertIn('No uncommitted changes', result.final_output)
|
||||
|
||||
def test_diff_shows_changes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
subprocess.run(
|
||||
['git', 'init'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'config', 'user.email', 'test@test.com'],
|
||||
cwd=str(workspace), capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'config', 'user.name', 'Test'],
|
||||
cwd=str(workspace), capture_output=True,
|
||||
)
|
||||
(workspace / 'hello.txt').write_text('hello\n')
|
||||
subprocess.run(
|
||||
['git', 'add', '.'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'commit', '-m', 'init'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
# Make a change
|
||||
(workspace / 'hello.txt').write_text('hello world\n')
|
||||
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent.run('/diff')
|
||||
self.assertIn('hello', result.final_output)
|
||||
self.assertIn('diff', result.final_output.lower())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Tests for /files, /copy, /export, /stats, /tag, /rename, /branch, /effort, /doctor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_session import AgentMessage, AgentSessionState
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig, UsageStats
|
||||
|
||||
|
||||
def _make_agent(tmp_dir: str) -> LocalCodingAgent:
|
||||
return LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
|
||||
|
||||
def _set_session(agent: LocalCodingAgent, messages: list[AgentMessage]) -> None:
|
||||
session = AgentSessionState(
|
||||
system_prompt_parts=('You are a helper.',),
|
||||
messages=messages,
|
||||
)
|
||||
agent.last_session = session
|
||||
|
||||
|
||||
class TestFilesCommand(unittest.TestCase):
|
||||
def test_no_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/files')
|
||||
self.assertIn('No active session', result.final_output)
|
||||
|
||||
def test_no_files_in_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi'),
|
||||
])
|
||||
result = agent.run('/files')
|
||||
self.assertIn('No files loaded', result.final_output)
|
||||
|
||||
def test_files_from_tool_calls(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Read main.py'),
|
||||
AgentMessage(
|
||||
role='assistant', content='',
|
||||
tool_calls=(
|
||||
{'id': 'tc1', 'type': 'function', 'function': {
|
||||
'name': 'Read',
|
||||
'arguments': json.dumps({'file_path': '/home/user/project/main.py'}),
|
||||
}},
|
||||
),
|
||||
),
|
||||
AgentMessage(
|
||||
role='tool', content='print("hello")',
|
||||
name='Read',
|
||||
tool_call_id='tc1',
|
||||
metadata={'path': '/home/user/project/main.py'},
|
||||
),
|
||||
])
|
||||
result = agent.run('/files')
|
||||
self.assertIn('Files in context', result.final_output)
|
||||
self.assertIn('main.py', result.final_output)
|
||||
|
||||
|
||||
class TestCopyCommand(unittest.TestCase):
|
||||
def test_no_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/copy')
|
||||
self.assertIn('No active session', result.final_output)
|
||||
|
||||
def test_no_assistant_messages(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
])
|
||||
result = agent.run('/copy')
|
||||
self.assertIn('No assistant responses', result.final_output)
|
||||
|
||||
def test_copies_latest_response(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='First response.'),
|
||||
AgentMessage(role='user', content='More'),
|
||||
AgentMessage(role='assistant', content='Second response with details.'),
|
||||
])
|
||||
result = agent.run('/copy')
|
||||
self.assertIn('Copied', result.final_output)
|
||||
self.assertIn('response.md', result.final_output)
|
||||
# Verify the file was written
|
||||
tmp_file = Path(tempfile.gettempdir()) / 'claw-code' / 'response.md'
|
||||
self.assertTrue(tmp_file.exists())
|
||||
content = tmp_file.read_text()
|
||||
self.assertEqual(content, 'Second response with details.')
|
||||
|
||||
def test_copies_nth_response(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='First.'),
|
||||
AgentMessage(role='user', content='More'),
|
||||
AgentMessage(role='assistant', content='Second.'),
|
||||
])
|
||||
result = agent.run('/copy 1')
|
||||
tmp_file = Path(tempfile.gettempdir()) / 'claw-code' / 'response.md'
|
||||
content = tmp_file.read_text()
|
||||
self.assertEqual(content, 'First.')
|
||||
|
||||
|
||||
class TestExportCommand(unittest.TestCase):
|
||||
def test_no_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/export')
|
||||
self.assertIn('No active session', result.final_output)
|
||||
|
||||
def test_exports_with_auto_filename(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi there'),
|
||||
])
|
||||
result = agent.run('/export')
|
||||
self.assertIn('Exported 2 messages', result.final_output)
|
||||
self.assertIn('.txt', result.final_output)
|
||||
|
||||
def test_exports_with_custom_filename(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi'),
|
||||
])
|
||||
result = agent.run('/export my_chat')
|
||||
self.assertIn('my_chat.txt', result.final_output)
|
||||
out_file = Path(tmp) / 'my_chat.txt'
|
||||
self.assertTrue(out_file.exists())
|
||||
content = out_file.read_text()
|
||||
self.assertIn('Hello', content)
|
||||
self.assertIn('Hi', content)
|
||||
|
||||
|
||||
class TestStatsCommand(unittest.TestCase):
|
||||
def test_shows_statistics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi'),
|
||||
AgentMessage(role='user', content='Question'),
|
||||
])
|
||||
agent.cumulative_usage = UsageStats(input_tokens=500, output_tokens=200)
|
||||
result = agent.run('/stats')
|
||||
self.assertIn('Session Statistics', result.final_output)
|
||||
self.assertIn('3 total', result.final_output)
|
||||
self.assertIn('2 user', result.final_output)
|
||||
self.assertIn('1 assistant', result.final_output)
|
||||
self.assertIn('500', result.final_output)
|
||||
self.assertIn('200', result.final_output)
|
||||
|
||||
|
||||
class TestTagCommand(unittest.TestCase):
|
||||
def test_no_tags_initially(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/tag')
|
||||
self.assertIn('No tags set', result.final_output)
|
||||
|
||||
def test_add_tag(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/tag important')
|
||||
self.assertIn('Added tag: important', result.final_output)
|
||||
|
||||
def test_toggle_tag(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
agent.run('/tag my-tag')
|
||||
result = agent.run('/tag my-tag')
|
||||
self.assertIn('Removed tag: my-tag', result.final_output)
|
||||
|
||||
def test_list_tags(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
agent.run('/tag alpha')
|
||||
agent.run('/tag beta')
|
||||
result = agent.run('/tag')
|
||||
self.assertIn('alpha', result.final_output)
|
||||
self.assertIn('beta', result.final_output)
|
||||
|
||||
|
||||
class TestRenameCommand(unittest.TestCase):
|
||||
def test_no_name(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/rename')
|
||||
self.assertIn('Usage', result.final_output)
|
||||
|
||||
def test_rename_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/rename My Cool Session')
|
||||
self.assertIn('renamed to: My Cool Session', result.final_output)
|
||||
|
||||
|
||||
class TestBranchCommand(unittest.TestCase):
|
||||
def test_no_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/branch')
|
||||
self.assertIn('No active session', result.final_output)
|
||||
|
||||
def test_branch_with_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi'),
|
||||
])
|
||||
result = agent.run('/branch my-feature')
|
||||
self.assertIn('Created branch "my-feature"', result.final_output)
|
||||
self.assertIn('Saved to:', result.final_output)
|
||||
|
||||
def test_branch_auto_name(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
])
|
||||
result = agent.run('/branch')
|
||||
self.assertIn('Created branch "branch-', result.final_output)
|
||||
|
||||
|
||||
class TestEffortCommand(unittest.TestCase):
|
||||
def test_show_current_effort(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/effort')
|
||||
self.assertIn('Current effort level: auto', result.final_output)
|
||||
|
||||
def test_set_effort_level(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/effort high')
|
||||
self.assertIn('Set effort level to: high', result.final_output)
|
||||
|
||||
def test_invalid_effort_level(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/effort extreme')
|
||||
self.assertIn('Invalid effort level', result.final_output)
|
||||
|
||||
def test_all_valid_levels(self) -> None:
|
||||
for level in ('low', 'medium', 'high', 'max', 'auto'):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run(f'/effort {level}')
|
||||
self.assertIn(f'Set effort level to: {level}', result.final_output)
|
||||
|
||||
|
||||
class TestDoctorCommand(unittest.TestCase):
|
||||
def test_shows_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/doctor')
|
||||
output = result.final_output
|
||||
self.assertIn('Doctor Report', output)
|
||||
self.assertIn('Python version', output)
|
||||
self.assertIn('git', output)
|
||||
self.assertIn('Model', output)
|
||||
self.assertIn('Working directory', output)
|
||||
|
||||
def test_detects_claude_md(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / 'CLAUDE.md').write_text('memory file')
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/doctor')
|
||||
self.assertIn('CLAUDE.md', result.final_output)
|
||||
self.assertIn('found', result.final_output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,643 @@
|
||||
"""Tests for prompt_constants module.
|
||||
|
||||
Validates that all constants ported from npm src/constants/ are present,
|
||||
correctly typed, and that helper functions behave as expected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.prompt_constants import (
|
||||
# Product metadata
|
||||
PRODUCT_URL,
|
||||
CLAUDE_AI_BASE_URL,
|
||||
# System prompt prefixes
|
||||
DEFAULT_SYSPROMPT_PREFIX,
|
||||
AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX,
|
||||
AGENT_SDK_PREFIX,
|
||||
CLI_SYSPROMPT_PREFIXES,
|
||||
# Cyber risk
|
||||
CYBER_RISK_INSTRUCTION,
|
||||
# API limits
|
||||
API_IMAGE_MAX_BASE64_SIZE,
|
||||
IMAGE_TARGET_RAW_SIZE,
|
||||
IMAGE_MAX_WIDTH,
|
||||
IMAGE_MAX_HEIGHT,
|
||||
PDF_TARGET_RAW_SIZE,
|
||||
API_PDF_MAX_PAGES,
|
||||
PDF_EXTRACT_SIZE_THRESHOLD,
|
||||
PDF_MAX_EXTRACT_SIZE,
|
||||
PDF_MAX_PAGES_PER_READ,
|
||||
PDF_AT_MENTION_INLINE_THRESHOLD,
|
||||
API_MAX_MEDIA_PER_REQUEST,
|
||||
# Tool limits
|
||||
DEFAULT_MAX_RESULT_SIZE_CHARS,
|
||||
MAX_TOOL_RESULT_TOKENS,
|
||||
BYTES_PER_TOKEN,
|
||||
MAX_TOOL_RESULT_BYTES,
|
||||
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS,
|
||||
TOOL_SUMMARY_MAX_LENGTH,
|
||||
# Spinner verbs
|
||||
SPINNER_VERBS,
|
||||
# Turn completion verbs
|
||||
TURN_COMPLETION_VERBS,
|
||||
# Figures
|
||||
BLACK_CIRCLE,
|
||||
BULLET_OPERATOR,
|
||||
TEARDROP_ASTERISK,
|
||||
UP_ARROW,
|
||||
DOWN_ARROW,
|
||||
LIGHTNING_BOLT,
|
||||
EFFORT_LOW,
|
||||
EFFORT_MEDIUM,
|
||||
EFFORT_HIGH,
|
||||
EFFORT_MAX,
|
||||
PLAY_ICON,
|
||||
PAUSE_ICON,
|
||||
REFRESH_ARROW,
|
||||
CHANNEL_ARROW,
|
||||
INJECTED_ARROW,
|
||||
FORK_GLYPH,
|
||||
DIAMOND_OPEN,
|
||||
DIAMOND_FILLED,
|
||||
REFERENCE_MARK,
|
||||
FLAG_ICON,
|
||||
BLOCKQUOTE_BAR,
|
||||
HEAVY_HORIZONTAL,
|
||||
BRIDGE_SPINNER_FRAMES,
|
||||
BRIDGE_READY_INDICATOR,
|
||||
BRIDGE_FAILED_INDICATOR,
|
||||
# XML tags
|
||||
COMMAND_NAME_TAG,
|
||||
COMMAND_MESSAGE_TAG,
|
||||
COMMAND_ARGS_TAG,
|
||||
BASH_INPUT_TAG,
|
||||
BASH_STDOUT_TAG,
|
||||
BASH_STDERR_TAG,
|
||||
LOCAL_COMMAND_STDOUT_TAG,
|
||||
LOCAL_COMMAND_STDERR_TAG,
|
||||
LOCAL_COMMAND_CAVEAT_TAG,
|
||||
TERMINAL_OUTPUT_TAGS,
|
||||
TICK_TAG,
|
||||
TASK_NOTIFICATION_TAG,
|
||||
TASK_ID_TAG,
|
||||
TOOL_USE_ID_TAG,
|
||||
TASK_TYPE_TAG,
|
||||
OUTPUT_FILE_TAG,
|
||||
STATUS_TAG,
|
||||
SUMMARY_TAG,
|
||||
REASON_TAG,
|
||||
WORKTREE_TAG,
|
||||
WORKTREE_PATH_TAG,
|
||||
WORKTREE_BRANCH_TAG,
|
||||
ULTRAPLAN_TAG,
|
||||
REMOTE_REVIEW_TAG,
|
||||
REMOTE_REVIEW_PROGRESS_TAG,
|
||||
TEAMMATE_MESSAGE_TAG,
|
||||
CHANNEL_MESSAGE_TAG,
|
||||
CHANNEL_TAG,
|
||||
CROSS_SESSION_MESSAGE_TAG,
|
||||
FORK_BOILERPLATE_TAG,
|
||||
FORK_DIRECTIVE_PREFIX,
|
||||
COMMON_HELP_ARGS,
|
||||
COMMON_INFO_ARGS,
|
||||
# Messages
|
||||
NO_CONTENT_MESSAGE,
|
||||
# Date utilities
|
||||
get_local_iso_date,
|
||||
get_session_start_date,
|
||||
reset_session_start_date,
|
||||
get_local_month_year,
|
||||
# System prompt section caching
|
||||
SystemPromptSection,
|
||||
system_prompt_section,
|
||||
dangerous_uncached_system_prompt_section,
|
||||
resolve_system_prompt_sections,
|
||||
clear_system_prompt_sections,
|
||||
# Output styles
|
||||
DEFAULT_OUTPUT_STYLE_NAME,
|
||||
OutputStyleConfig,
|
||||
OUTPUT_STYLE_CONFIGS,
|
||||
# Knowledge cutoff
|
||||
FRONTIER_MODEL_NAME,
|
||||
get_knowledge_cutoff,
|
||||
CLAUDE_MODEL_IDS,
|
||||
# Prompt sections
|
||||
HOOKS_SECTION,
|
||||
SYSTEM_REMINDERS_SECTION,
|
||||
SUMMARIZE_TOOL_RESULTS_SECTION,
|
||||
DEFAULT_AGENT_PROMPT,
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
get_language_section,
|
||||
get_output_style_section,
|
||||
get_scratchpad_instructions,
|
||||
# Error IDs
|
||||
E_TOOL_USE_SUMMARY_GENERATION_FAILED,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Product metadata
|
||||
# =========================================================================
|
||||
|
||||
class TestProductMetadata:
|
||||
def test_product_url(self):
|
||||
assert PRODUCT_URL == "https://claude.com/claude-code"
|
||||
|
||||
def test_claude_ai_base_url(self):
|
||||
assert CLAUDE_AI_BASE_URL == "https://claude.ai"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# System prompt prefixes
|
||||
# =========================================================================
|
||||
|
||||
class TestSystemPromptPrefixes:
|
||||
def test_default_prefix_content(self):
|
||||
assert "Claude Code" in DEFAULT_SYSPROMPT_PREFIX
|
||||
assert "Anthropic" in DEFAULT_SYSPROMPT_PREFIX
|
||||
|
||||
def test_agent_sdk_prefix_content(self):
|
||||
assert "Agent SDK" in AGENT_SDK_PREFIX
|
||||
|
||||
def test_cli_sysprompt_prefixes_is_frozenset(self):
|
||||
assert isinstance(CLI_SYSPROMPT_PREFIXES, frozenset)
|
||||
assert len(CLI_SYSPROMPT_PREFIXES) == 3
|
||||
|
||||
def test_all_prefixes_in_set(self):
|
||||
assert DEFAULT_SYSPROMPT_PREFIX in CLI_SYSPROMPT_PREFIXES
|
||||
assert AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX in CLI_SYSPROMPT_PREFIXES
|
||||
assert AGENT_SDK_PREFIX in CLI_SYSPROMPT_PREFIXES
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cyber risk
|
||||
# =========================================================================
|
||||
|
||||
class TestCyberRisk:
|
||||
def test_instruction_mentions_ctf(self):
|
||||
assert "CTF" in CYBER_RISK_INSTRUCTION
|
||||
|
||||
def test_instruction_mentions_dos(self):
|
||||
assert "DoS" in CYBER_RISK_INSTRUCTION
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# API limits
|
||||
# =========================================================================
|
||||
|
||||
class TestAPILimits:
|
||||
def test_image_base64_size(self):
|
||||
assert API_IMAGE_MAX_BASE64_SIZE == 5 * 1024 * 1024
|
||||
|
||||
def test_image_target_raw_size(self):
|
||||
assert IMAGE_TARGET_RAW_SIZE == (API_IMAGE_MAX_BASE64_SIZE * 3) // 4
|
||||
|
||||
def test_image_dimensions(self):
|
||||
assert IMAGE_MAX_WIDTH == 2000
|
||||
assert IMAGE_MAX_HEIGHT == 2000
|
||||
|
||||
def test_pdf_target_raw_size(self):
|
||||
assert PDF_TARGET_RAW_SIZE == 20 * 1024 * 1024
|
||||
|
||||
def test_pdf_max_pages(self):
|
||||
assert API_PDF_MAX_PAGES == 100
|
||||
|
||||
def test_pdf_extract_threshold(self):
|
||||
assert PDF_EXTRACT_SIZE_THRESHOLD == 3 * 1024 * 1024
|
||||
|
||||
def test_pdf_max_extract_size(self):
|
||||
assert PDF_MAX_EXTRACT_SIZE == 100 * 1024 * 1024
|
||||
|
||||
def test_pdf_pages_per_read(self):
|
||||
assert PDF_MAX_PAGES_PER_READ == 20
|
||||
|
||||
def test_pdf_inline_threshold(self):
|
||||
assert PDF_AT_MENTION_INLINE_THRESHOLD == 10
|
||||
|
||||
def test_media_per_request(self):
|
||||
assert API_MAX_MEDIA_PER_REQUEST == 100
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Tool limits
|
||||
# =========================================================================
|
||||
|
||||
class TestToolLimits:
|
||||
def test_default_max_result_size(self):
|
||||
assert DEFAULT_MAX_RESULT_SIZE_CHARS == 50_000
|
||||
|
||||
def test_max_tool_result_tokens(self):
|
||||
assert MAX_TOOL_RESULT_TOKENS == 100_000
|
||||
|
||||
def test_bytes_per_token(self):
|
||||
assert BYTES_PER_TOKEN == 4
|
||||
|
||||
def test_max_tool_result_bytes_derived(self):
|
||||
assert MAX_TOOL_RESULT_BYTES == MAX_TOOL_RESULT_TOKENS * BYTES_PER_TOKEN
|
||||
assert MAX_TOOL_RESULT_BYTES == 400_000
|
||||
|
||||
def test_max_per_message_chars(self):
|
||||
assert MAX_TOOL_RESULTS_PER_MESSAGE_CHARS == 200_000
|
||||
|
||||
def test_tool_summary_max_length(self):
|
||||
assert TOOL_SUMMARY_MAX_LENGTH == 50
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Spinner verbs
|
||||
# =========================================================================
|
||||
|
||||
class TestSpinnerVerbs:
|
||||
def test_is_tuple(self):
|
||||
assert isinstance(SPINNER_VERBS, tuple)
|
||||
|
||||
def test_count(self):
|
||||
assert len(SPINNER_VERBS) == 187
|
||||
|
||||
def test_first_verb(self):
|
||||
assert SPINNER_VERBS[0] == "Accomplishing"
|
||||
|
||||
def test_last_verb(self):
|
||||
assert SPINNER_VERBS[-1] == "Zigzagging"
|
||||
|
||||
def test_all_strings(self):
|
||||
for verb in SPINNER_VERBS:
|
||||
assert isinstance(verb, str)
|
||||
|
||||
def test_contains_clauding(self):
|
||||
assert "Clauding" in SPINNER_VERBS
|
||||
|
||||
def test_contains_thinking(self):
|
||||
assert "Thinking" in SPINNER_VERBS
|
||||
|
||||
def test_no_duplicates(self):
|
||||
assert len(SPINNER_VERBS) == len(set(SPINNER_VERBS))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Turn completion verbs
|
||||
# =========================================================================
|
||||
|
||||
class TestTurnCompletionVerbs:
|
||||
def test_is_tuple(self):
|
||||
assert isinstance(TURN_COMPLETION_VERBS, tuple)
|
||||
|
||||
def test_count(self):
|
||||
assert len(TURN_COMPLETION_VERBS) == 8
|
||||
|
||||
def test_contains_worked(self):
|
||||
assert "Worked" in TURN_COMPLETION_VERBS
|
||||
|
||||
def test_contains_baked(self):
|
||||
assert "Baked" in TURN_COMPLETION_VERBS
|
||||
|
||||
def test_all_past_tense(self):
|
||||
# All end in 'd' (past tense)
|
||||
for verb in TURN_COMPLETION_VERBS:
|
||||
assert verb[-1] == "d", f"{verb} doesn't end with 'd'"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Figures / UI symbols
|
||||
# =========================================================================
|
||||
|
||||
class TestFigures:
|
||||
def test_black_circle_is_string(self):
|
||||
assert isinstance(BLACK_CIRCLE, str)
|
||||
assert len(BLACK_CIRCLE) == 1
|
||||
|
||||
def test_effort_symbols_are_distinct(self):
|
||||
symbols = {EFFORT_LOW, EFFORT_MEDIUM, EFFORT_HIGH, EFFORT_MAX}
|
||||
assert len(symbols) == 4
|
||||
|
||||
def test_arrows(self):
|
||||
assert UP_ARROW == "\u2191"
|
||||
assert DOWN_ARROW == "\u2193"
|
||||
|
||||
def test_bridge_spinner_frames(self):
|
||||
assert isinstance(BRIDGE_SPINNER_FRAMES, tuple)
|
||||
assert len(BRIDGE_SPINNER_FRAMES) == 4
|
||||
|
||||
def test_play_pause_icons(self):
|
||||
assert PLAY_ICON == "\u25b6"
|
||||
assert PAUSE_ICON == "\u23f8"
|
||||
|
||||
def test_diamond_symbols(self):
|
||||
assert DIAMOND_OPEN == "\u25c7"
|
||||
assert DIAMOND_FILLED == "\u25c6"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# XML tag constants
|
||||
# =========================================================================
|
||||
|
||||
class TestXMLTags:
|
||||
def test_command_tags(self):
|
||||
assert COMMAND_NAME_TAG == "command-name"
|
||||
assert COMMAND_MESSAGE_TAG == "command-message"
|
||||
assert COMMAND_ARGS_TAG == "command-args"
|
||||
|
||||
def test_bash_tags(self):
|
||||
assert BASH_INPUT_TAG == "bash-input"
|
||||
assert BASH_STDOUT_TAG == "bash-stdout"
|
||||
assert BASH_STDERR_TAG == "bash-stderr"
|
||||
|
||||
def test_terminal_output_tags_tuple(self):
|
||||
assert isinstance(TERMINAL_OUTPUT_TAGS, tuple)
|
||||
assert len(TERMINAL_OUTPUT_TAGS) == 6
|
||||
assert BASH_INPUT_TAG in TERMINAL_OUTPUT_TAGS
|
||||
assert LOCAL_COMMAND_STDOUT_TAG in TERMINAL_OUTPUT_TAGS
|
||||
|
||||
def test_tick_tag(self):
|
||||
assert TICK_TAG == "tick"
|
||||
|
||||
def test_task_tags(self):
|
||||
assert TASK_NOTIFICATION_TAG == "task-notification"
|
||||
assert TASK_ID_TAG == "task-id"
|
||||
assert TOOL_USE_ID_TAG == "tool-use-id"
|
||||
|
||||
def test_worktree_tags(self):
|
||||
assert WORKTREE_TAG == "worktree"
|
||||
assert WORKTREE_PATH_TAG == "worktreePath"
|
||||
|
||||
def test_fork_tags(self):
|
||||
assert FORK_BOILERPLATE_TAG == "fork-boilerplate"
|
||||
assert FORK_DIRECTIVE_PREFIX == "Your directive: "
|
||||
|
||||
def test_common_help_args(self):
|
||||
assert isinstance(COMMON_HELP_ARGS, tuple)
|
||||
assert "help" in COMMON_HELP_ARGS
|
||||
assert "-h" in COMMON_HELP_ARGS
|
||||
assert "--help" in COMMON_HELP_ARGS
|
||||
|
||||
def test_common_info_args(self):
|
||||
assert isinstance(COMMON_INFO_ARGS, tuple)
|
||||
assert "list" in COMMON_INFO_ARGS
|
||||
assert "status" in COMMON_INFO_ARGS
|
||||
assert "?" in COMMON_INFO_ARGS
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Message constants
|
||||
# =========================================================================
|
||||
|
||||
class TestMessages:
|
||||
def test_no_content_message(self):
|
||||
assert NO_CONTENT_MESSAGE == "(no content)"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Date utilities
|
||||
# =========================================================================
|
||||
|
||||
class TestDateUtilities:
|
||||
def test_get_local_iso_date_format(self):
|
||||
d = get_local_iso_date()
|
||||
parts = d.split("-")
|
||||
assert len(parts) == 3
|
||||
assert len(parts[0]) == 4 # year
|
||||
assert len(parts[1]) == 2 # month
|
||||
assert len(parts[2]) == 2 # day
|
||||
|
||||
def test_get_local_iso_date_override(self):
|
||||
with patch.dict(os.environ, {"CLAUDE_CODE_OVERRIDE_DATE": "2025-01-15"}):
|
||||
assert get_local_iso_date() == "2025-01-15"
|
||||
|
||||
def test_get_session_start_date_memoised(self):
|
||||
reset_session_start_date()
|
||||
d1 = get_session_start_date()
|
||||
d2 = get_session_start_date()
|
||||
assert d1 == d2
|
||||
|
||||
def test_reset_session_start_date(self):
|
||||
reset_session_start_date()
|
||||
d = get_session_start_date()
|
||||
assert isinstance(d, str)
|
||||
reset_session_start_date()
|
||||
# After reset, should still return valid date
|
||||
d2 = get_session_start_date()
|
||||
assert isinstance(d2, str)
|
||||
|
||||
def test_get_local_month_year_format(self):
|
||||
result = get_local_month_year()
|
||||
parts = result.split()
|
||||
assert len(parts) == 2
|
||||
assert parts[1].isdigit()
|
||||
assert len(parts[1]) == 4
|
||||
|
||||
def test_get_local_month_year_override(self):
|
||||
with patch.dict(os.environ, {"CLAUDE_CODE_OVERRIDE_DATE": "2026-02-15"}):
|
||||
assert get_local_month_year() == "February 2026"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# System prompt section caching
|
||||
# =========================================================================
|
||||
|
||||
class TestSystemPromptSections:
|
||||
def setup_method(self):
|
||||
clear_system_prompt_sections()
|
||||
|
||||
def test_system_prompt_section_creates_cached(self):
|
||||
s = system_prompt_section("test", lambda: "hello")
|
||||
assert s.name == "test"
|
||||
assert s.cache_break is False
|
||||
|
||||
def test_dangerous_uncached_creates_volatile(self):
|
||||
s = dangerous_uncached_system_prompt_section("test", lambda: "hello", "reason")
|
||||
assert s.name == "test"
|
||||
assert s.cache_break is True
|
||||
|
||||
def test_resolve_caches_sections(self):
|
||||
call_count = 0
|
||||
|
||||
def compute():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"value-{call_count}"
|
||||
|
||||
sections = [system_prompt_section("s1", compute)]
|
||||
r1 = resolve_system_prompt_sections(sections)
|
||||
r2 = resolve_system_prompt_sections(sections)
|
||||
assert r1 == ["value-1"]
|
||||
assert r2 == ["value-1"] # cached
|
||||
assert call_count == 1
|
||||
|
||||
def test_uncached_recomputes(self):
|
||||
call_count = 0
|
||||
|
||||
def compute():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"value-{call_count}"
|
||||
|
||||
sections = [dangerous_uncached_system_prompt_section("s2", compute, "test")]
|
||||
r1 = resolve_system_prompt_sections(sections)
|
||||
r2 = resolve_system_prompt_sections(sections)
|
||||
assert r1 == ["value-1"]
|
||||
assert r2 == ["value-2"] # recomputed
|
||||
assert call_count == 2
|
||||
|
||||
def test_clear_resets_cache(self):
|
||||
call_count = 0
|
||||
|
||||
def compute():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"value-{call_count}"
|
||||
|
||||
sections = [system_prompt_section("s3", compute)]
|
||||
resolve_system_prompt_sections(sections)
|
||||
clear_system_prompt_sections()
|
||||
r = resolve_system_prompt_sections(sections)
|
||||
assert r == ["value-2"]
|
||||
assert call_count == 2
|
||||
|
||||
def test_resolve_handles_none(self):
|
||||
sections = [system_prompt_section("nil", lambda: None)]
|
||||
r = resolve_system_prompt_sections(sections)
|
||||
assert r == [None]
|
||||
|
||||
def test_multiple_sections(self):
|
||||
sections = [
|
||||
system_prompt_section("a", lambda: "alpha"),
|
||||
system_prompt_section("b", lambda: "beta"),
|
||||
system_prompt_section("c", lambda: None),
|
||||
]
|
||||
r = resolve_system_prompt_sections(sections)
|
||||
assert r == ["alpha", "beta", None]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Output styles
|
||||
# =========================================================================
|
||||
|
||||
class TestOutputStyles:
|
||||
def test_default_style_name(self):
|
||||
assert DEFAULT_OUTPUT_STYLE_NAME == "default"
|
||||
|
||||
def test_default_style_is_none(self):
|
||||
assert OUTPUT_STYLE_CONFIGS[DEFAULT_OUTPUT_STYLE_NAME] is None
|
||||
|
||||
def test_explanatory_exists(self):
|
||||
style = OUTPUT_STYLE_CONFIGS["Explanatory"]
|
||||
assert style is not None
|
||||
assert style.name == "Explanatory"
|
||||
assert "explains" in style.description
|
||||
|
||||
def test_learning_exists(self):
|
||||
style = OUTPUT_STYLE_CONFIGS["Learning"]
|
||||
assert style is not None
|
||||
assert style.name == "Learning"
|
||||
assert "hands-on" in style.description
|
||||
|
||||
def test_output_style_config_frozen(self):
|
||||
style = OutputStyleConfig(
|
||||
name="Test", description="desc", prompt="prompt"
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
style.name = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Knowledge cutoff
|
||||
# =========================================================================
|
||||
|
||||
class TestKnowledgeCutoff:
|
||||
def test_frontier_model_name(self):
|
||||
assert FRONTIER_MODEL_NAME == "Claude Opus 4.6"
|
||||
|
||||
def test_opus_46_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-opus-4-6-20250601") == "May 2025"
|
||||
|
||||
def test_sonnet_46_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-sonnet-4-6-20250801") == "August 2025"
|
||||
|
||||
def test_opus_45_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-opus-4-5-20250601") == "May 2025"
|
||||
|
||||
def test_haiku_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-haiku-4-20250201") == "February 2025"
|
||||
|
||||
def test_sonnet_4_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-sonnet-4-20250114") == "January 2025"
|
||||
|
||||
def test_unknown_model_returns_none(self):
|
||||
assert get_knowledge_cutoff("gpt-4-turbo") is None
|
||||
|
||||
def test_claude_model_ids(self):
|
||||
assert "opus" in CLAUDE_MODEL_IDS
|
||||
assert "sonnet" in CLAUDE_MODEL_IDS
|
||||
assert "haiku" in CLAUDE_MODEL_IDS
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Prompt section helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestPromptSectionHelpers:
|
||||
def test_hooks_section_content(self):
|
||||
assert "hooks" in HOOKS_SECTION
|
||||
assert "user-prompt-submit-hook" in HOOKS_SECTION
|
||||
|
||||
def test_system_reminders_section(self):
|
||||
assert "system-reminder" in SYSTEM_REMINDERS_SECTION
|
||||
|
||||
def test_summarize_tool_results(self):
|
||||
assert "tool results" in SUMMARIZE_TOOL_RESULTS_SECTION
|
||||
|
||||
def test_default_agent_prompt(self):
|
||||
assert "agent for Claude Code" in DEFAULT_AGENT_PROMPT
|
||||
|
||||
def test_dynamic_boundary(self):
|
||||
assert SYSTEM_PROMPT_DYNAMIC_BOUNDARY == "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"
|
||||
|
||||
def test_language_section_none_when_no_preference(self):
|
||||
assert get_language_section(None) is None
|
||||
assert get_language_section("") is None
|
||||
|
||||
def test_language_section_with_preference(self):
|
||||
result = get_language_section("Spanish")
|
||||
assert result is not None
|
||||
assert "Spanish" in result
|
||||
assert "# Language" in result
|
||||
|
||||
def test_output_style_section_none_when_no_config(self):
|
||||
assert get_output_style_section(None) is None
|
||||
|
||||
def test_output_style_section_with_config(self):
|
||||
config = OutputStyleConfig(
|
||||
name="TestStyle",
|
||||
description="A test style",
|
||||
prompt="Be concise.",
|
||||
)
|
||||
result = get_output_style_section(config)
|
||||
assert result is not None
|
||||
assert "# Output Style: TestStyle" in result
|
||||
assert "Be concise." in result
|
||||
|
||||
def test_scratchpad_none_when_no_dir(self):
|
||||
assert get_scratchpad_instructions(None) is None
|
||||
assert get_scratchpad_instructions("") is None
|
||||
|
||||
def test_scratchpad_with_dir(self):
|
||||
result = get_scratchpad_instructions("/tmp/session-123")
|
||||
assert result is not None
|
||||
assert "/tmp/session-123" in result
|
||||
assert "# Scratchpad Directory" in result
|
||||
assert "temporary files" in result
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Error IDs
|
||||
# =========================================================================
|
||||
|
||||
class TestErrorIDs:
|
||||
def test_tool_use_summary_error(self):
|
||||
assert E_TOOL_USE_SUMMARY_GENERATION_FAILED == 344
|
||||
Reference in New Issue
Block a user