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:
Abdelrahman Abdallah
2026-04-08 00:02:53 +02:00
parent 90489e7bfc
commit aacf0a212a
13 changed files with 5872 additions and 304 deletions
+878
View File
@@ -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
+405
View File
@@ -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()
+189
View File
@@ -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()
+299
View File
@@ -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()
+643
View File
@@ -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